You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-26-remote-mode.md

76 KiB

Remote Mode Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add Host/Remote mode so the app on a MacBook can control playback on a Mac Mini over the local network.

Architecture: PlayerViewModel becomes the single source of truth for all playback state — Views never read from AudioService directly. In local mode, PlayerViewModel syncs state from AudioService via a callback. In remote mode, it syncs from the network. This eliminates all if isRemoteMode branching from the View layer. Networking uses Network.framework with Bonjour discovery, HTTP for one-shot DB download, and NDJSON (newline-delimited JSON) over TCP for the bidirectional command channel.

Tech Stack: Network.framework (NWListener, NWConnection, NWBrowser), Bonjour/mDNS, os.Logger, GRDB (existing), Swift Codable for JSON protocol.

Architectural note: The spec describes a PlaybackController protocol with separate implementations. This plan achieves the same goal (Views unchanged between modes) more simply: PlayerViewModel handles both modes internally with clear if/else routing. The protocol added a layer of indirection that obscured what was happening, and ContentView still ended up needing isRemoteMode checks for reading state. This approach eliminates all of that.


File Structure

New Files

File Responsibility
Music/Remote/RemoteProtocol.swift RemoteCommand and HostEvent Codable enums, protocol version constant, PlaybackStatePayload, HandshakeMessage
Music/Remote/RemoteLogger.swift os.Logger wrappers with host and client categories under com.music.remote subsystem
Music/Remote/ConnectionState.swift Connection state machine enum with transition validation and user-visible messages
Music/Remote/NDJSONTransport.swift Newline-delimited JSON framing over NWConnection — line-buffered reads, typed sends. Shared by host and client.
Music/Remote/HostServer.swift NWListener-based server: Bonjour advertisement, HTTP DB serving, command channel via NDJSONTransport, command dispatch to PlayerViewModel
Music/Remote/RemoteClient.swift NWBrowser-based client: Bonjour discovery, DB download, command channel via NDJSONTransport, state receiving
Music/Views/ConnectionSheet.swift SwiftUI sheet for discovering and connecting to hosts
MusicTests/RemoteProtocolTests.swift Codable round-trip tests for all message types
MusicTests/ConnectionStateTests.swift State machine transition tests
MusicTests/NDJSONTransportTests.swift Line-buffered framing tests
MusicTests/PlayerViewModelRemoteTests.swift PlayerViewModel remote mode tests: command forwarding, state syncing
MusicTests/HostServerIntegrationTests.swift Loopback integration: DB download, command/state round-trip

Modified Files

File Changes
Music/Services/AudioService.swift Add onPlaybackStateChanged callback, fire on time updates and discrete state changes
Music/Services/DatabaseService.swift Add fetchTracksByIds(_:) method for efficient ID-based lookups
Music/ViewModels/PlayerViewModel.swift Add isPlaying, currentTime, duration, volume properties. Add enterRemoteMode/exitRemoteMode. Route all playback actions through local audio or remote commands.
Music/ContentView.swift Replace all audio.* reads with player.*. Remove audio property. Add networkStatus for banner/indicators.
Music/Views/PlaylistBarView.swift Add isRemoteMode to disable context menus
Music/MusicApp.swift Add HostServer, RemoteClient, menu items, DB swap on connect/disconnect

Task 1: Message Protocol — RemoteCommand and HostEvent

Files:

  • Create: Music/Remote/RemoteProtocol.swift

  • Test: MusicTests/RemoteProtocolTests.swift

  • Step 1: Write failing tests for RemoteCommand encoding/decoding

// MusicTests/RemoteProtocolTests.swift
import Testing
import Foundation
@testable import Music

struct RemoteProtocolTests {

    // Encodes each RemoteCommand to JSON and decodes it back,
    // verifying the round-trip preserves the value exactly.

    @Test func playCommandRoundTrip() throws {
        let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45])
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func pauseCommandRoundTrip() throws {
        let cmd = RemoteCommand.pause
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func resumeCommandRoundTrip() throws {
        let cmd = RemoteCommand.resume
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func nextCommandRoundTrip() throws {
        let cmd = RemoteCommand.next
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func previousCommandRoundTrip() throws {
        let cmd = RemoteCommand.previous
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func seekCommandRoundTrip() throws {
        let cmd = RemoteCommand.seek(position: 65.3)
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func setVolumeCommandRoundTrip() throws {
        let cmd = RemoteCommand.setVolume(level: 0.75)
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func toggleShuffleCommandRoundTrip() throws {
        let cmd = RemoteCommand.toggleShuffle
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func refreshDBCommandRoundTrip() throws {
        let cmd = RemoteCommand.refreshDB
        let data = try JSONEncoder().encode(cmd)
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data)
        #expect(decoded == cmd)
    }

    @Test func playbackStateEventRoundTrip() throws {
        let event = HostEvent.playbackState(PlaybackStatePayload(
            trackId: 42, isPlaying: true, currentTime: 65.3,
            duration: 210.0, volume: 0.75, isShuffled: false
        ))
        let data = try JSONEncoder().encode(event)
        let decoded = try JSONDecoder().decode(HostEvent.self, from: data)
        #expect(decoded == event)
    }

    @Test func dbReadyEventRoundTrip() throws {
        let event = HostEvent.dbReady
        let data = try JSONEncoder().encode(event)
        let decoded = try JSONDecoder().decode(HostEvent.self, from: data)
        #expect(decoded == event)
    }

    @Test func errorEventRoundTrip() throws {
        let event = HostEvent.error(message: "Track file not found")
        let data = try JSONEncoder().encode(event)
        let decoded = try JSONDecoder().decode(HostEvent.self, from: data)
        #expect(decoded == event)
    }

    @Test func handshakeRoundTrip() throws {
        let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.0.0")
        let data = try JSONEncoder().encode(msg)
        let decoded = try JSONDecoder().decode(HandshakeMessage.self, from: data)
        #expect(decoded == msg)
    }

    // Decodes from known JSON strings to verify wire format matches the spec.
    @Test func playCommandDecodesFromWireFormat() throws {
        let json = """
        {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}}
        """
        let decoded = try JSONDecoder().decode(RemoteCommand.self, from: Data(json.utf8))
        #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45]))
    }

    @Test func playbackStateDecodesFromWireFormat() throws {
        let json = """
        {"type":"playbackState","payload":{"trackId":42,"isPlaying":true,"currentTime":65.3,"duration":210.0,"volume":0.75,"isShuffled":false}}
        """
        let decoded = try JSONDecoder().decode(HostEvent.self, from: Data(json.utf8))
        #expect(decoded == .playbackState(PlaybackStatePayload(
            trackId: 42, isPlaying: true, currentTime: 65.3,
            duration: 210.0, volume: 0.75, isShuffled: false
        )))
    }
}
  • Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20

Expected: Compilation failure — RemoteCommand, HostEvent, etc. not defined.

  • Step 3: Implement the protocol types
// Music/Remote/RemoteProtocol.swift
import Foundation

let RemoteProtocolVersion: Int = 1

nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable {
    var trackId: Int64?
    var isPlaying: Bool
    var currentTime: Double
    var duration: Double
    var volume: Float
    var isShuffled: Bool
}

nonisolated struct HandshakeMessage: Codable, Equatable, Sendable {
    var protocolVersion: Int
    var appVersion: String
}

nonisolated enum RemoteCommand: Codable, Equatable, Sendable {
    case play(trackId: Int64, queueIds: [Int64])
    case pause
    case resume
    case next
    case previous
    case seek(position: Double)
    case setVolume(level: Float)
    case toggleShuffle
    case refreshDB

    private enum CodingKeys: String, CodingKey {
        case type, payload
    }

    private enum CommandType: String, Codable {
        case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB
    }

    private struct PlayPayload: Codable { let trackId: Int64; let queueIds: [Int64] }
    private struct SeekPayload: Codable { let position: Double }
    private struct VolumePayload: Codable { let level: Float }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        switch self {
        case .play(let trackId, let queueIds):
            try container.encode(CommandType.play, forKey: .type)
            try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload)
        case .pause: try container.encode(CommandType.pause, forKey: .type)
        case .resume: try container.encode(CommandType.resume, forKey: .type)
        case .next: try container.encode(CommandType.next, forKey: .type)
        case .previous: try container.encode(CommandType.previous, forKey: .type)
        case .seek(let position):
            try container.encode(CommandType.seek, forKey: .type)
            try container.encode(SeekPayload(position: position), forKey: .payload)
        case .setVolume(let level):
            try container.encode(CommandType.setVolume, forKey: .type)
            try container.encode(VolumePayload(level: level), forKey: .payload)
        case .toggleShuffle: try container.encode(CommandType.toggleShuffle, forKey: .type)
        case .refreshDB: try container.encode(CommandType.refreshDB, forKey: .type)
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(CommandType.self, forKey: .type)
        switch type {
        case .play:
            let p = try container.decode(PlayPayload.self, forKey: .payload)
            self = .play(trackId: p.trackId, queueIds: p.queueIds)
        case .pause: self = .pause
        case .resume: self = .resume
        case .next: self = .next
        case .previous: self = .previous
        case .seek:
            let p = try container.decode(SeekPayload.self, forKey: .payload)
            self = .seek(position: p.position)
        case .setVolume:
            let p = try container.decode(VolumePayload.self, forKey: .payload)
            self = .setVolume(level: p.level)
        case .toggleShuffle: self = .toggleShuffle
        case .refreshDB: self = .refreshDB
        }
    }
}

nonisolated enum HostEvent: Codable, Equatable, Sendable {
    case playbackState(PlaybackStatePayload)
    case dbReady
    case error(message: String)

    private enum CodingKeys: String, CodingKey { case type, payload }
    private enum EventType: String, Codable { case playbackState, dbReady, error }
    private struct ErrorPayload: Codable { let message: String }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        switch self {
        case .playbackState(let payload):
            try container.encode(EventType.playbackState, forKey: .type)
            try container.encode(payload, forKey: .payload)
        case .dbReady: try container.encode(EventType.dbReady, forKey: .type)
        case .error(let message):
            try container.encode(EventType.error, forKey: .type)
            try container.encode(ErrorPayload(message: message), forKey: .payload)
        }
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(EventType.self, forKey: .type)
        switch type {
        case .playbackState:
            self = .playbackState(try container.decode(PlaybackStatePayload.self, forKey: .payload))
        case .dbReady: self = .dbReady
        case .error:
            self = .error(message: try container.decode(ErrorPayload.self, forKey: .payload).message)
        }
    }
}
  • Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20

Expected: All 15 tests pass.

  • Step 5: Commit
git add Music/Remote/RemoteProtocol.swift MusicTests/RemoteProtocolTests.swift
git commit -m "feat(remote): add RemoteCommand and HostEvent protocol types with tests"

Task 2: Remote Logger

Files:

  • Create: Music/Remote/RemoteLogger.swift

  • Step 1: Implement the logger

// Music/Remote/RemoteLogger.swift
import os

enum RemoteLogger {
    static let host = Logger(subsystem: "com.music.remote", category: "host")
    static let client = Logger(subsystem: "com.music.remote", category: "client")
}
  • Step 2: Commit
git add Music/Remote/RemoteLogger.swift
git commit -m "feat(remote): add structured os.Logger for host and client"

Task 3: Connection State Machine

Files:

  • Create: Music/Remote/ConnectionState.swift

  • Test: MusicTests/ConnectionStateTests.swift

  • Step 1: Write failing tests

// MusicTests/ConnectionStateTests.swift
import Testing
import Foundation
@testable import Music

struct ConnectionStateTests {

    // Verifies that each valid forward transition is allowed.
    @Test func validTransitionsSucceed() {
        #expect(ConnectionState.disconnected.canTransition(to: .discovering) == true)
        #expect(ConnectionState.discovering.canTransition(to: .foundHost("Mac Mini")) == true)
        #expect(ConnectionState.foundHost("Mac Mini").canTransition(to: .downloadingDB) == true)
        #expect(ConnectionState.downloadingDB.canTransition(to: .connectingCommandChannel) == true)
        #expect(ConnectionState.connectingCommandChannel.canTransition(to: .connected("Mac Mini")) == true)
        #expect(ConnectionState.connected("Mac Mini").canTransition(to: .connectionLost("Network changed")) == true)
        #expect(ConnectionState.connectionLost("Network changed").canTransition(to: .discovering) == true)
    }

    // Verifies that skipping states is rejected.
    @Test func invalidTransitionsRejected() {
        #expect(ConnectionState.disconnected.canTransition(to: .connected("Mac Mini")) == false)
        #expect(ConnectionState.discovering.canTransition(to: .connected("Mac Mini")) == false)
        #expect(ConnectionState.connected("Mac Mini").canTransition(to: .discovering) == false)
    }

    // Any state can transition to disconnected (user can always disconnect).
    @Test func anyStateCanDisconnect() {
        #expect(ConnectionState.discovering.canTransition(to: .disconnected) == true)
        #expect(ConnectionState.foundHost("X").canTransition(to: .disconnected) == true)
        #expect(ConnectionState.downloadingDB.canTransition(to: .disconnected) == true)
        #expect(ConnectionState.connectingCommandChannel.canTransition(to: .disconnected) == true)
        #expect(ConnectionState.connected("X").canTransition(to: .disconnected) == true)
    }

    // Verifies the human-readable status message for each state.
    @Test func userVisibleDescriptions() {
        #expect(ConnectionState.disconnected.userMessage == nil)
        #expect(ConnectionState.discovering.userMessage == "Searching for hosts...")
        #expect(ConnectionState.foundHost("Mac Mini").userMessage == "Found Mac Mini")
        #expect(ConnectionState.downloadingDB.userMessage == "Downloading library...")
        #expect(ConnectionState.connectingCommandChannel.userMessage == "Connecting...")
        #expect(ConnectionState.connected("Mac Mini").userMessage == "Connected to Mac Mini")
        #expect(ConnectionState.connectionLost("Network changed").userMessage == "Connection lost — Network changed")
    }
}
  • Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20

Expected: Compilation failure.

  • Step 3: Implement the state machine
// Music/Remote/ConnectionState.swift
import Foundation

nonisolated enum ConnectionState: Equatable, Sendable {
    case disconnected
    case discovering
    case foundHost(String)
    case downloadingDB
    case connectingCommandChannel
    case connected(String)
    case connectionLost(String)

    var userMessage: String? {
        switch self {
        case .disconnected: nil
        case .discovering: "Searching for hosts..."
        case .foundHost(let name): "Found \(name)"
        case .downloadingDB: "Downloading library..."
        case .connectingCommandChannel: "Connecting..."
        case .connected(let name): "Connected to \(name)"
        case .connectionLost(let reason): "Connection lost — \(reason)"
        }
    }

    var isConnected: Bool {
        if case .connected = self { return true }
        return false
    }

    func canTransition(to next: ConnectionState) -> Bool {
        if next == .disconnected { return true }
        switch (self, next) {
        case (.disconnected, .discovering),
             (.discovering, .foundHost),
             (.foundHost, .downloadingDB),
             (.downloadingDB, .connectingCommandChannel),
             (.connectingCommandChannel, .connected),
             (.connected, .connectionLost),
             (.connectionLost, .discovering):
            return true
        default:
            return false
        }
    }
}
  • Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20

Expected: All 4 tests pass.

  • Step 5: Commit
git add Music/Remote/ConnectionState.swift MusicTests/ConnectionStateTests.swift
git commit -m "feat(remote): add ConnectionState machine with transition validation"

Task 4: NDJSONTransport — Shared Line-Buffered Framing

Files:

  • Create: Music/Remote/NDJSONTransport.swift
  • Test: MusicTests/NDJSONTransportTests.swift

This class handles the newline-delimited JSON framing over a raw TCP connection. Both HostServer and RemoteClient use it for their command channels, eliminating duplicated line-buffering logic.

  • Step 1: Write failing tests
// MusicTests/NDJSONTransportTests.swift
import Testing
import Foundation
@testable import Music

struct NDJSONTransportTests {

    // Verifies that processReceivedData correctly splits newline-delimited
    // input into individual lines, handling partial lines across calls.
    @Test func splitsLinesCorrectly() {
        var lines: [String] = []
        let transport = NDJSONLineBuffer { lines.append($0) }

        transport.feed(Data("{\"type\":\"pause\"}\n{\"type\":\"resume\"}\n".utf8))
        #expect(lines == ["{\"type\":\"pause\"}", "{\"type\":\"resume\"}"])
    }

    // Verifies that a line split across two TCP reads is reassembled.
    @Test func handlesPartialLines() {
        var lines: [String] = []
        let transport = NDJSONLineBuffer { lines.append($0) }

        transport.feed(Data("{\"type\":\"pa".utf8))
        #expect(lines.isEmpty)

        transport.feed(Data("use\"}\n".utf8))
        #expect(lines == ["{\"type\":\"pause\"}"])
    }

    // Verifies empty lines are ignored.
    @Test func ignoresEmptyLines() {
        var lines: [String] = []
        let transport = NDJSONLineBuffer { lines.append($0) }

        transport.feed(Data("\n\n{\"type\":\"next\"}\n\n".utf8))
        #expect(lines == ["{\"type\":\"next\"}"])
    }
}
  • Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20

Expected: Compilation failure.

  • Step 3: Implement NDJSONTransport
// Music/Remote/NDJSONTransport.swift
import Foundation
import Network

final class NDJSONLineBuffer: @unchecked Sendable {
    private var buffer = ""
    private let onLine: (String) -> Void

    init(onLine: @escaping (String) -> Void) {
        self.onLine = onLine
    }

    func feed(_ data: Data) {
        guard let text = String(data: data, encoding: .utf8) else { return }
        buffer += text
        while let newlineIndex = buffer.firstIndex(of: "\n") {
            let line = String(buffer[buffer.startIndex..<newlineIndex])
            buffer = String(buffer[buffer.index(after: newlineIndex)...])
            if !line.isEmpty {
                onLine(line)
            }
        }
    }

    func reset() {
        buffer = ""
    }
}

@MainActor
final class NDJSONTransport {
    private let connection: NWConnection
    private let lineBuffer: NDJSONLineBuffer
    private let logger: os.Logger
    var onLine: ((String) -> Void)?
    var onClose: (() -> Void)?

    init(connection: NWConnection, logger: os.Logger) {
        self.connection = connection
        self.logger = logger
        self.lineBuffer = NDJSONLineBuffer { [weak self] line in
            self?.onLine?(line)
        }
    }

    func send<T: Encodable>(_ message: T) {
        do {
            var data = try JSONEncoder().encode(message)
            data.append(contentsOf: "\n".utf8)
            connection.send(content: data, completion: .contentProcessed { [logger] error in
                if let error {
                    logger.error("Send error: \(error.localizedDescription)")
                }
            })
        } catch {
            logger.error("Encode error: \(error.localizedDescription)")
        }
    }

    func startReceiving() {
        receiveNext()
    }

    func close() {
        connection.cancel()
    }

    private func receiveNext() {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
            Task { @MainActor in
                guard let self else { return }
                if let data {
                    self.lineBuffer.feed(data)
                }
                if isComplete {
                    self.logger.info("Connection closed by peer")
                    self.onClose?()
                } else if let error {
                    self.logger.error("Receive error: \(error.localizedDescription)")
                    self.onClose?()
                } else {
                    self.receiveNext()
                }
            }
        }
    }
}
  • Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20

Expected: All 3 tests pass.

  • Step 5: Commit
git add Music/Remote/NDJSONTransport.swift MusicTests/NDJSONTransportTests.swift
git commit -m "feat(remote): add NDJSONTransport for line-buffered JSON framing over TCP"

Task 5: DatabaseService.fetchTracksByIds

Files:

  • Modify: Music/Services/DatabaseService.swift

  • Modify: MusicTests/DatabaseServiceTests.swift

  • Step 1: Write failing test

Append to MusicTests/DatabaseServiceTests.swift:

// Inserts 5 tracks, fetches 3 by ID, verifies only those 3 are returned
// in the order of the requested IDs.
@Test func fetchTracksByIds() throws {
    let db = try DatabaseService(inMemory: true)
    var tracks = (0..<5).map { i in
        Track.fixture(fileURL: "/track\(i).mp3", title: "Track \(i)")
    }
    for i in tracks.indices {
        try db.insert(&tracks[i])
    }

    let ids: [Int64] = [tracks[2].id!, tracks[0].id!, tracks[4].id!]
    let result = try db.fetchTracksByIds(ids)

    #expect(result.count == 3)
    #expect(result[0].id == tracks[2].id)
    #expect(result[1].id == tracks[0].id)
    #expect(result[2].id == tracks[4].id)
}
  • Step 2: Run test to verify it fails

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20

Expected: Compilation failure — fetchTracksByIds not defined.

  • Step 3: Implement the method

Add to DatabaseService.swift in the // MARK: - Read section:

func fetchTracksByIds(_ ids: [Int64]) throws -> [Track] {
    guard !ids.isEmpty else { return [] }
    let tracks = try dbPool.read { db in
        let placeholders = databaseQuestionMarks(count: ids.count)
        return try Track.fetchAll(
            db,
            sql: "SELECT * FROM tracks WHERE id IN (\(placeholders))",
            arguments: StatementArguments(ids)
        )
    }
    let trackMap = Dictionary(uniqueKeysWithValues: tracks.compactMap { t in t.id.map { ($0, t) } })
    return ids.compactMap { trackMap[$0] }
}
  • Step 4: Run test to verify it passes

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20

Expected: PASS.

  • Step 5: Commit
git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift
git commit -m "feat(remote): add DatabaseService.fetchTracksByIds for efficient ID-based lookups"

Task 6: Refactor PlayerViewModel to Own Playback State

This is the key architectural change. PlayerViewModel becomes the single source of truth for ALL playback state. Views will read from PlayerViewModel instead of AudioService.

Files:

  • Modify: Music/Services/AudioService.swift

  • Modify: Music/ViewModels/PlayerViewModel.swift

  • Modify: MusicTests/PlayerViewModelTests.swift

  • Step 1: Add onPlaybackStateChanged callback to AudioService

In AudioService.swift, add a new callback alongside onTrackFinished:

var onPlaybackStateChanged: (() -> Void)?

Fire it from the periodic time observer (inside the existing closure, after updating currentTime and duration):

self.onPlaybackStateChanged?()

Also fire it at the end of play(url:) (after isPlaying = true), pause() (after isPlaying = false), resume() (after isPlaying = true), and stop() (after setting all state). Also fire in the endObserver callback (after isPlaying = false).

  • Step 2: Add playback state properties to PlayerViewModel

In PlayerViewModel.swift, add new stored properties:

var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65

Add a private helper and the sync callback in init:

private var remoteClient: RemoteClient?
var trackResolver: ((Int64) -> Track?)?

private var isRemote: Bool { remoteClient != nil }

At the end of init, add:

audio.onPlaybackStateChanged = { [weak self] in
    self?.syncFromAudio()
}

Add the sync method:

private func syncFromAudio() {
    guard !isRemote else { return }
    isPlaying = audio.isPlaying
    if !audio.isScrubbing {
        currentTime = audio.currentTime
    }
    duration = audio.duration
    checkHalfway()
}
  • Step 3: Route playback actions through PlayerViewModel

Add these methods to PlayerViewModel:

func togglePlayPause() {
    if isPlaying { pause() } else { resume() }
}

func pause() {
    isPlaying = false
    if isRemote { remoteClient!.sendCommand(.pause) } else { audio.pause() }
}

func resume() {
    isPlaying = true
    if isRemote { remoteClient!.sendCommand(.resume) } else { audio.resume() }
}

func seek(to position: Double) {
    currentTime = position
    if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.seek(to: position) }
}

func setVolume(_ level: Float) {
    volume = level
    if isRemote { remoteClient!.sendCommand(.setVolume(level: level)) } else { audio.volume = level }
}

func beginScrubbing() {
    if !isRemote { audio.beginScrubbing() }
}

func scrub(to position: Double) {
    currentTime = position
    if !isRemote { audio.scrub(to: position) }
}

func endScrubbing(at position: Double) {
    currentTime = position
    if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) }
}

func stop() {
    isPlaying = false
    currentTime = 0
    duration = 0
    currentTrack = nil
    currentIndex = nil
    if !isRemote { audio.stop() }
}

Update the existing play(_ track:) method to also update the new state properties:

func play(_ track: Track) {
    currentTrack = track
    currentIndex = queue.firstIndex(where: { $0.id == track.id })
    halfwayReported = false
    isPlaying = true
    currentTime = 0

    if let client = remoteClient {
        guard let trackId = track.id else { return }
        client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id)))
    } else {
        guard let url = URL(string: track.fileURL) else { return }
        audio.play(url: url)
    }
}

Update next():

func next() {
    if isRemote {
        remoteClient!.sendCommand(.next)
        return
    }
    guard let idx = currentIndex else { return }
    let nextIdx = idx + 1
    if nextIdx < queue.count {
        play(queue[nextIdx])
    } else {
        stop()
    }
}

Update previous():

func previous() {
    if isRemote {
        remoteClient!.sendCommand(.previous)
        return
    }
    guard let idx = currentIndex else { return }
    let prevIdx = max(0, idx - 1)
    play(queue[prevIdx])
}

Update toggleShuffle():

func toggleShuffle() {
    isShuffled.toggle()
    if isRemote {
        remoteClient!.sendCommand(.toggleShuffle)
        return
    }
    if isShuffled {
        queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack)
    } else {
        queue = originalQueue
    }
    if let current = currentTrack {
        currentIndex = queue.firstIndex(where: { $0.id == current.id })
    }
}

Update trackDidFinish() to guard against remote mode:

private func trackDidFinish() {
    guard !isRemote else { return }
    if let track = currentTrack, let trackId = track.id, !halfwayReported {
        let newCount = track.playCount + 1
        try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())
    }
    next()
}
  • Step 4: Add remote mode entry/exit methods
func enterRemoteMode(client: RemoteClient) {
    audio.stop()
    remoteClient = client
    currentTrack = nil
    currentIndex = nil
    isPlaying = false
    currentTime = 0
    duration = 0
    queue = []
    originalQueue = []
}

func exitRemoteMode() {
    remoteClient = nil
    trackResolver = nil
    currentTrack = nil
    currentIndex = nil
    isPlaying = false
    currentTime = 0
    duration = 0
    queue = []
    originalQueue = []
}

func applyRemoteState(_ state: PlaybackStatePayload) {
    guard isRemote else { return }
    isPlaying = state.isPlaying
    currentTime = state.currentTime
    duration = state.duration
    volume = state.volume
    isShuffled = state.isShuffled

    if let trackId = state.trackId, currentTrack?.id != trackId {
        currentTrack = trackResolver?(trackId)
    } else if state.trackId == nil {
        currentTrack = nil
    }
}
  • Step 5: Run existing PlayerViewModel tests

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/PlayerViewModelTests 2>&1 | tail -20

Expected: All 6 existing tests pass — they test queue/track logic, not playback state.

  • Step 6: Commit
git add Music/Services/AudioService.swift Music/ViewModels/PlayerViewModel.swift
git commit -m "refactor: make PlayerViewModel single source of truth for all playback state"

Task 7: Update ContentView to Read from PlayerViewModel

This removes ContentView's direct dependency on AudioService. No behavior change — just rewiring where state is read from.

Files:

  • Modify: Music/ContentView.swift

  • Modify: Music/MusicApp.swift

  • Step 1: Remove audio property from ContentView and replace all references

In ContentView.swift:

Remove the audio property:

// DELETE: var audio: AudioService

Replace playerControls:

private var playerControls: some View {
    PlayerControlsView(
        currentTrack: player.currentTrack,
        isPlaying: player.isPlaying,
        currentTime: player.currentTime,
        duration: player.duration,
        volume: player.volume,
        isShuffled: player.isShuffled,
        onPlayPause: {
            if player.currentTrack == nil {
                let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
                if let first = trackList.first {
                    player.setQueue(trackList)
                    player.play(first)
                }
            } else {
                player.togglePlayPause()
            }
        },
        onNext: { player.next() },
        onPrevious: { player.previous() },
        onSeek: { player.seek(to: $0) },
        onScrubStart: { player.beginScrubbing() },
        onScrub: { player.scrub(to: $0) },
        onScrubEnd: { player.endScrubbing(at: $0) },
        onVolumeChange: { player.setVolume($0) },
        onShuffleToggle: { player.toggleShuffle() },
        onNowPlayingTap: { scrollToPlayingTrigger = UUID() }
    )
}

Remove the .onChange(of: audio.currentTime) handler — checkHalfway is now called internally by syncFromAudio():

// DELETE: .onChange(of: audio.currentTime) { _, _ in
//             player.checkHalfway()
//         }

Update installKeyboardMonitor — remove audio from capture list, replace audio.togglePlayPause() with player.togglePlayPause():

keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [player, library, playlist] event in
    guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else {
        return event
    }
    guard let responder = NSApp.keyWindow?.firstResponder,
          !(responder is NSTextView) else {
        return event
    }
    switch event.keyCode {
    case 49: // space
        if player.currentTrack == nil {
            let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
            if let first = trackList.first {
                player.setQueue(trackList)
                player.play(first)
            }
        } else {
            player.togglePlayPause()
        }
        return nil
    case 123: // left arrow
        player.previous()
        return nil
    case 124: // right arrow
        player.next()
        return nil
    default:
        return event
    }
}
  • Step 2: Update MusicApp ContentView call to remove audio parameter

In MusicApp.swift, remove the audio: audioService parameter from the ContentView(...) call.

  • Step 3: Build and run existing tests

Run: xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Executed|FAIL)'

Expected: All tests pass. This is a pure refactor — no behavior change.

  • Step 4: Commit
git add Music/ContentView.swift Music/MusicApp.swift
git commit -m "refactor: remove AudioService from ContentView — all playback state via PlayerViewModel"

Task 8: HostServer — Bonjour + HTTP + Command Channel

Files:

  • Create: Music/Remote/HostServer.swift

  • Test: MusicTests/HostServerIntegrationTests.swift

  • Step 1: Write failing integration test for DB download

// MusicTests/HostServerIntegrationTests.swift
import Testing
import Foundation
import Network
@testable import Music

@MainActor
struct HostServerIntegrationTests {

    // Starts a HostServer, connects via TCP, sends GET /db,
    // verifies the response is valid SQLite data.
    @Test(.timeLimit(.minutes(1)))
    func dbDownloadReturnsValidSQLite() async throws {
        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)

        let server = HostServer(dbPath: dbPath)
        try server.start()
        let port = server.actualPort!

        let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db")

        let header = String(data: responseData.prefix(16), encoding: .utf8) ?? ""
        #expect(header.hasPrefix("SQLite format 3"))

        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)

        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 {
        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)

        let audio = AudioService()
        let player = PlayerViewModel(audio: audio, db: nil)
        let server = HostServer(dbPath: dbPath)
        server.configure(player: player, db: nil)
        try server.start()
        let port = server.actualPort!

        // Connect and send GET /cmd to establish command channel.
        let connection = try await connectCommandChannel(host: "127.0.0.1", port: port)

        // Send a pause command.
        let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause)
        var lineData = pauseCmd
        lineData.append(contentsOf: "\n".utf8)
        connection.send(content: lineData, completion: .contentProcessed { _ in })

        // Wait for a playbackState response.
        let responseLine = try await receiveOneLine(on: connection)
        let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8))

        if case .playbackState(let payload) = event {
            #expect(payload.isPlaying == false)
        } else {
            Issue.record("Expected playbackState, got \(event)")
        }

        connection.cancel()
        server.stop()
        try? FileManager.default.removeItem(at: tempDir)
    }

    // MARK: - Helpers

    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 {
                    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 })
                    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)) {
                            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)
        }
    }

    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 {
                    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 })
                    // Read the 200 OK response.
                    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)
        }
    }

    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) } ?? ""
                    continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text)
                }
            }
        }
    }
}
  • Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20

Expected: Compilation failure.

  • Step 3: Implement HostServer
// Music/Remote/HostServer.swift
import Foundation
import Network
import Observation

@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 player: PlayerViewModel?
    private var db: DatabaseService?
    private var stateTimer: Timer?

    init(dbPath: String) {
        self.dbPath = dbPath
    }

    func configure(player: PlayerViewModel, db: DatabaseService?) {
        self.player = player
        self.db = db
    }

    func start() throws {
        let params = NWParameters.tcp
        params.includePeerToPeer = true
        let listener = try NWListener(using: params)
        listener.service = NWListener.Service(name: Host.current().localizedName, type: "_musicremote._tcp")

        listener.stateUpdateHandler = { [weak self] state in
            Task { @MainActor in
                switch state {
                case .ready:
                    if let port = listener.port?.rawValue {
                        self?.actualPort = port
                        RemoteLogger.host.info("Host started on port \(port)")
                    }
                case .failed(let error):
                    RemoteLogger.host.error("Listener failed: \(error.localizedDescription)")
                    self?.stop()
                default: break
                }
            }
        }

        listener.newConnectionHandler = { [weak self] connection in
            Task { @MainActor in self?.handleNewConnection(connection) }
        }

        listener.start(queue: .main)
        self.listener = listener
        isHosting = true
    }

    func stop() {
        stateTimer?.invalidate()
        stateTimer = nil
        commandTransport?.close()
        commandTransport = nil
        connectedRemoteName = nil
        listener?.cancel()
        listener = nil
        actualPort = nil
        isHosting = false
        RemoteLogger.host.info("Host stopped")
    }

    // MARK: - Connection Handling

    private func handleNewConnection(_ connection: NWConnection) {
        connection.stateUpdateHandler = { [weak self] state in
            Task { @MainActor in
                switch state {
                case .ready:
                    self?.receiveInitialRequest(on: connection)
                case .failed(let error):
                    RemoteLogger.host.error("Connection failed: \(error.localizedDescription)")
                    connection.cancel()
                case .cancelled:
                    if self?.commandTransport != nil {
                        self?.commandTransport = nil
                        self?.connectedRemoteName = nil
                        self?.stateTimer?.invalidate()
                        self?.stateTimer = nil
                    }
                default: break
                }
            }
        }
        connection.start(queue: .main)
    }

    private func receiveInitialRequest(on connection: NWConnection) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, error in
            Task { @MainActor in
                guard let self, let data, let request = String(data: data, encoding: .utf8) else {
                    if let error { RemoteLogger.host.error("Receive error: \(error.localizedDescription)") }
                    connection.cancel()
                    return
                }
                RemoteLogger.host.debug("Request: \(request.prefix(80))")

                if request.hasPrefix("GET /db") {
                    self.serveDatabase(on: connection)
                } else if request.hasPrefix("GET /cmd") {
                    self.setupCommandChannel(on: connection)
                } else {
                    self.sendHTTP(status: "404 Not Found", body: nil, on: connection, close: true)
                }
            }
        }
    }

    // MARK: - HTTP DB Download

    private func serveDatabase(on connection: NWConnection) {
        do {
            let data = try Data(contentsOf: URL(fileURLWithPath: dbPath))
            RemoteLogger.host.info("Serving database: \(data.count) bytes")
            sendHTTP(status: "200 OK", body: data, contentType: "application/octet-stream", on: connection, close: true)
        } catch {
            RemoteLogger.host.error("Failed to read database: \(error.localizedDescription)")
            sendHTTP(status: "500 Internal Server Error", body: nil, on: connection, close: true)
        }
    }

    private func sendHTTP(status: String, body: Data?, contentType: String = "text/plain", on connection: NWConnection, close: Bool) {
        let bodyLen = body?.count ?? 0
        let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyLen)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n"
        var response = Data(header.utf8)
        if let body { response.append(body) }
        connection.send(content: response, completion: .contentProcessed { _ in
            if close { connection.cancel() }
        })
    }

    // MARK: - Command Channel

    private func setupCommandChannel(on connection: NWConnection) {
        if commandTransport != nil {
            RemoteLogger.host.info("Rejecting second remote — already connected")
            sendHTTP(status: "409 Conflict", body: nil, on: connection, close: true)
            return
        }

        let response = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nConnection: keep-alive\r\n\r\n"
        connection.send(content: Data(response.utf8), completion: .contentProcessed { [weak self] _ in
            Task { @MainActor in
                guard let self else { return }
                let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.host)
                transport.onLine = { [weak self] line in self?.handleCommandLine(line) }
                transport.onClose = { [weak self] in
                    Task { @MainActor in
                        RemoteLogger.host.info("Remote disconnected")
                        self?.commandTransport = nil
                        self?.connectedRemoteName = nil
                        self?.stateTimer?.invalidate()
                        self?.stateTimer = nil
                    }
                }
                transport.startReceiving()

                self.commandTransport = transport
                self.connectedRemoteName = "Remote"
                self.startStateUpdates()
                RemoteLogger.host.info("Command channel established")
            }
        })
    }

    private func handleCommandLine(_ line: String) {
        guard let data = line.data(using: .utf8) else { return }

        if let handshake = try? JSONDecoder().decode(HandshakeMessage.self, from: data) {
            RemoteLogger.host.info("Handshake: protocol v\(handshake.protocolVersion), app v\(handshake.appVersion)")
            if handshake.protocolVersion != RemoteProtocolVersion {
                RemoteLogger.host.error("Protocol mismatch: host v\(RemoteProtocolVersion), remote v\(handshake.protocolVersion)")
                commandTransport?.send(HostEvent.error(message: "Protocol version mismatch"))
            }
            connectedRemoteName = handshake.appVersion
            return
        }

        do {
            let command = try JSONDecoder().decode(RemoteCommand.self, from: data)
            RemoteLogger.host.debug("Command: \(line.prefix(80))")
            executeCommand(command)
        } catch {
            RemoteLogger.host.error("Decode failed: \(error.localizedDescription)")
        }
    }

    private func executeCommand(_ command: RemoteCommand) {
        guard let player else { return }

        switch command {
        case .play(let trackId, let queueIds):
            guard let db else {
                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 {
                    commandTransport?.send(HostEvent.error(message: "Track not found"))
                    return
                }
                player.setQueue(tracks)
                player.play(track)
            } catch {
                RemoteLogger.host.error("Failed to resolve tracks: \(error.localizedDescription)")
                commandTransport?.send(HostEvent.error(message: "Failed to load tracks"))
            }
        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:
            RemoteLogger.host.info("DB refresh requested")
            commandTransport?.send(HostEvent.dbReady)
        }

        sendCurrentState()
    }

    private func sendCurrentState() {
        guard let player, let transport = commandTransport else { return }
        transport.send(HostEvent.playbackState(PlaybackStatePayload(
            trackId: player.currentTrack?.id,
            isPlaying: player.isPlaying,
            currentTime: player.currentTime,
            duration: player.duration,
            volume: player.volume,
            isShuffled: player.isShuffled
        )))
    }

    private func startStateUpdates() {
        stateTimer?.invalidate()
        stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            Task { @MainActor in self?.sendCurrentState() }
        }
    }
}
  • Step 4: Run integration tests

Run: xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20

Expected: Both tests pass.

  • Step 5: Commit
git add Music/Remote/HostServer.swift MusicTests/HostServerIntegrationTests.swift
git commit -m "feat(remote): add HostServer with Bonjour, HTTP DB download, and NDJSON command channel"

Task 9: RemoteClient — Discovery, DB Download, Command Channel

Files:

  • Create: Music/Remote/RemoteClient.swift

  • Step 1: Implement RemoteClient

// Music/Remote/RemoteClient.swift
import Foundation
import Network
import Observation

@MainActor
@Observable
final class RemoteClient {
    var connectionState = ConnectionState.disconnected
    var discoveredHosts: [(name: String, endpoint: NWEndpoint)] = []
    var onPlaybackState: ((PlaybackStatePayload) -> Void)?
    var onDBReady: (() -> Void)?

    private var browser: NWBrowser?
    private var commandTransport: NDJSONTransport?
    private var hostEndpoint: NWEndpoint?
    private var pingTimer: Timer?
    private var missedPings = 0

    // MARK: - Discovery

    func startDiscovery() {
        transition(to: .discovering)
        discoveredHosts = []

        let params = NWParameters()
        params.includePeerToPeer = true
        let browser = NWBrowser(for: .bonjour(type: "_musicremote._tcp", domain: nil), using: params)

        browser.stateUpdateHandler = { [weak self] state in
            Task { @MainActor in
                if case .failed(let error) = state {
                    RemoteLogger.client.error("Browser failed: \(error.localizedDescription)")
                    self?.transition(to: .disconnected)
                }
            }
        }

        browser.browseResultsChangedHandler = { [weak self] results, _ in
            Task { @MainActor in
                self?.discoveredHosts = results.compactMap { result in
                    if case .service(let name, _, _, _) = result.endpoint {
                        return (name: name, endpoint: result.endpoint)
                    }
                    return nil
                }
                RemoteLogger.client.info("Discovered \(results.count) host(s)")
            }
        }

        browser.start(queue: .main)
        self.browser = browser
    }

    func stopDiscovery() {
        browser?.cancel()
        browser = nil
        discoveredHosts = []
        if case .discovering = connectionState { transition(to: .disconnected) }
    }

    // MARK: - Connect / Disconnect

    func connect(to host: (name: String, endpoint: NWEndpoint)) {
        transition(to: .foundHost(host.name))
        browser?.cancel()
        browser = nil

        let connection = NWConnection(to: host.endpoint, using: .tcp)
        connection.stateUpdateHandler = { [weak self] state in
            Task { @MainActor in
                switch state {
                case .ready:
                    self?.hostEndpoint = host.endpoint
                    self?.downloadDatabase(on: connection, hostName: host.name)
                case .failed(let error):
                    RemoteLogger.client.error("Connection failed: \(error.localizedDescription)")
                    self?.transition(to: .connectionLost(error.localizedDescription))
                default: break
                }
            }
        }
        connection.start(queue: .main)
    }

    func disconnect() {
        pingTimer?.invalidate()
        pingTimer = nil
        commandTransport?.close()
        commandTransport = nil
        browser?.cancel()
        browser = nil
        discoveredHosts = []
        transition(to: .disconnected)
        RemoteLogger.client.info("Disconnected")
        try? FileManager.default.removeItem(atPath: Self.remoteDBPath)
    }

    func sendCommand(_ command: RemoteCommand) {
        guard let transport = commandTransport else {
            RemoteLogger.client.error("No command channel — cannot send")
            return
        }
        transport.send(command)
        RemoteLogger.client.debug("Sent: \(String(describing: command))")
    }

    // MARK: - DB Download

    static var remoteDBPath: String {
        let appSupport = FileManager.default.urls(
            for: .applicationSupportDirectory, in: .userDomainMask
        ).first!.appendingPathComponent("Music", isDirectory: true)
        return appSupport.appendingPathComponent("remote_db.sqlite").path
    }

    private func downloadDatabase(on connection: NWConnection, hostName: String) {
        transition(to: .downloadingDB)
        let startTime = Date()
        let request = "GET /db HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n"

        connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in
            if let error {
                Task { @MainActor in
                    RemoteLogger.client.error("DB request failed: \(error.localizedDescription)")
                    self?.transition(to: .connectionLost(error.localizedDescription))
                }
                return
            }
            connection.receiveMessage { [weak self] data, _, _, error in
                Task { @MainActor in self?.handleDBResponse(data: data, error: error, startTime: startTime, hostName: hostName) }
            }
        })
    }

    private func handleDBResponse(data: Data?, error: NWError?, startTime: Date, hostName: String) {
        if let error {
            RemoteLogger.client.error("DB download error: \(error.localizedDescription)")
            transition(to: .connectionLost(error.localizedDescription))
            return
        }
        guard var data else {
            RemoteLogger.client.error("DB download returned no data")
            transition(to: .connectionLost("No data received"))
            return
        }
        if let range = data.range(of: Data("\r\n\r\n".utf8)) {
            data = Data(data[range.upperBound...])
        }

        let elapsed = Date().timeIntervalSince(startTime)
        RemoteLogger.client.info("DB downloaded (\(String(format: "%.1f", Double(data.count) / 1024)) KB, \(String(format: "%.0f", elapsed * 1000))ms)")

        do {
            let url = URL(fileURLWithPath: Self.remoteDBPath)
            try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
            try data.write(to: url)
        } catch {
            RemoteLogger.client.error("Failed to save remote DB: \(error.localizedDescription)")
            transition(to: .connectionLost("Failed to save database"))
            return
        }

        connectCommandChannel(hostName: hostName)
    }

    // MARK: - Command Channel

    private func connectCommandChannel(hostName: String) {
        transition(to: .connectingCommandChannel)
        guard let endpoint = hostEndpoint else {
            transition(to: .connectionLost("No host endpoint"))
            return
        }

        let connection = NWConnection(to: endpoint, using: .tcp)
        connection.stateUpdateHandler = { [weak self] state in
            Task { @MainActor in
                switch state {
                case .ready:
                    let request = "GET /cmd HTTP/1.1\r\nHost: local\r\nConnection: keep-alive\r\n\r\n"
                    connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
                    connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in
                        Task { @MainActor in self?.commandChannelReady(connection: connection, hostName: hostName) }
                    }
                case .failed(let error):
                    RemoteLogger.client.error("Command channel failed: \(error.localizedDescription)")
                    self?.transition(to: .connectionLost(error.localizedDescription))
                default: break
                }
            }
        }
        connection.start(queue: .main)
    }

    private func commandChannelReady(connection: NWConnection, hostName: String) {
        let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.client)
        transport.onLine = { [weak self] line in self?.handleEventLine(line) }
        transport.onClose = { [weak self] in
            Task { @MainActor in
                self?.commandTransport = nil
                self?.transition(to: .connectionLost("Host closed connection"))
                self?.pingTimer?.invalidate()
                self?.pingTimer = nil
            }
        }
        transport.startReceiving()

        self.commandTransport = transport
        transition(to: .connected(hostName))

        let handshake = HandshakeMessage(
            protocolVersion: RemoteProtocolVersion,
            appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
        )
        transport.send(handshake)
        startPingTimer()
    }

    private func handleEventLine(_ line: String) {
        guard !line.isEmpty, let data = line.data(using: .utf8) else { return }
        do {
            let event = try JSONDecoder().decode(HostEvent.self, from: data)
            switch event {
            case .playbackState(let payload):
                missedPings = 0
                onPlaybackState?(payload)
            case .dbReady:
                RemoteLogger.client.info("Host signals DB ready for re-download")
                onDBReady?()
            case .error(let message):
                RemoteLogger.client.error("Host error: \(message)")
            }
        } catch {
            RemoteLogger.client.error("Decode failed: \(error.localizedDescription)")
        }
    }

    // MARK: - Keep-alive

    private func startPingTimer() {
        missedPings = 0
        pingTimer?.invalidate()
        pingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
            Task { @MainActor in
                guard let self else { return }
                self.missedPings += 1
                if self.missedPings >= 3 {
                    RemoteLogger.client.error("No response from host for 15s — connection lost")
                    self.transition(to: .connectionLost("Host not responding"))
                    self.commandTransport?.close()
                    self.commandTransport = nil
                    self.pingTimer?.invalidate()
                    self.pingTimer = nil
                }
            }
        }
    }

    // MARK: - State

    private func transition(to newState: ConnectionState) {
        let oldState = connectionState
        if oldState == newState { return }
        guard oldState.canTransition(to: newState) else {
            RemoteLogger.client.error("Invalid transition: \(String(describing: oldState))\(String(describing: newState))")
            return
        }
        connectionState = newState
        RemoteLogger.client.info("State: \(newState.userMessage ?? "disconnected")")
    }
}
  • Step 2: Build and verify

Run: xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10

Expected: Build succeeds.

  • Step 3: Commit
git add Music/Remote/RemoteClient.swift
git commit -m "feat(remote): add RemoteClient with Bonjour discovery, DB download, NDJSON command channel"

Task 10: NetworkStatus Model

A small value type that encapsulates what ContentView needs to know about host/remote mode. Keeps networking details out of the View.

Files:

  • Create: Music/Remote/NetworkStatus.swift

  • Step 1: Implement NetworkStatus

// Music/Remote/NetworkStatus.swift
import Foundation

struct NetworkStatus {
    enum Mode {
        case hosting(connectedRemote: String?)
        case remote(hostName: String)
    }

    var mode: Mode
    var onDisconnect: (() -> Void)?
    var onRefreshLibrary: (() -> Void)?

    var isRemoteMode: Bool {
        if case .remote = mode { return true }
        return false
    }
}
  • Step 2: Commit
git add Music/Remote/NetworkStatus.swift
git commit -m "feat(remote): add NetworkStatus model for View-layer network state"

Task 11: Wire Host/Remote into MusicApp

Files:

  • Modify: Music/MusicApp.swift

  • Step 1: Add state properties

Add to MusicApp:

@State private var hostServer: HostServer?
@State private var remoteClient = RemoteClient()
@State private var showConnectionSheet = false
  • Step 2: Add menu items

Replace the .commands { ... } block:

.commands {
    CommandGroup(after: .newItem) {
        Button("Open Music Folder...") {
            pickFolder()
        }
        .keyboardShortcut("o")
        .disabled(remoteClient.connectionState.isConnected)

        Button("New Playlist...") {
            showNewPlaylistAlert = true
        }
        .keyboardShortcut("n")
        .disabled(remoteClient.connectionState.isConnected)

        Divider()

        Toggle("Enable Host Mode", isOn: Binding(
            get: { hostServer?.isHosting ?? false },
            set: { $0 ? startHosting() : hostServer?.stop() }
        ))
        .disabled(remoteClient.connectionState.isConnected)

        Button("Connect to Remote...") {
            showConnectionSheet = true
            remoteClient.startDiscovery()
        }
        .disabled(hostServer?.isHosting ?? false)
    }
}
  • Step 3: Add onChange for remote connection state and DB swap

Add to the Group in body, after .frame(...):

.onChange(of: remoteClient.connectionState) { _, newState in
    if case .connected = newState {
        enterRemoteMode()
    } else if newState == .disconnected {
        exitRemoteMode()
    }
}
.sheet(isPresented: $showConnectionSheet) {
    ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet)
}
  • Step 4: Add remote mode enter/exit methods
private func enterRemoteMode() {
    guard let player = playerVM else { return }
    do {
        let remoteDb = try DatabaseService(path: RemoteClient.remoteDBPath)
        self.libraryVM = LibraryViewModel(db: remoteDb)
        self.playlistVM = PlaylistViewModel(db: remoteDb)

        player.enterRemoteMode(client: remoteClient)
        player.trackResolver = { [weak self] trackId in
            self?.libraryVM?.tracks.first(where: { $0.id == trackId })
        }

        remoteClient.onPlaybackState = { [weak player] state in
            player?.applyRemoteState(state)
        }
    } catch {
        print("Failed to load remote DB: \(error)")
        remoteClient.disconnect()
    }
}

private func exitRemoteMode() {
    playerVM?.exitRemoteMode()
    remoteClient.onPlaybackState = nil
    guard let db = dbService else { return }
    self.libraryVM = LibraryViewModel(db: db)
    self.playlistVM = PlaylistViewModel(db: db)
    try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath)
}
  • Step 5: Add startHosting method
private func startHosting() {
    guard let db = dbService, let player = playerVM else { return }
    let appSupport = FileManager.default.urls(
        for: .applicationSupportDirectory, in: .userDomainMask
    ).first!.appendingPathComponent("Music", isDirectory: true)
    let dbPath = appSupport.appendingPathComponent("db.sqlite").path

    let server = HostServer(dbPath: dbPath)
    server.configure(player: player, db: db)
    do {
        try server.start()
        hostServer = server
    } catch {
        print("Failed to start host: \(error)")
    }
}
  • Step 6: Update ContentView call to pass networkStatus

Compute networkStatus and pass it:

ContentView(
    library: library,
    player: player,
    scanner: scanner,
    playlist: playlist,
    shazam: shazamService,
    db: db,
    showNewPlaylistAlert: $showNewPlaylistAlert,
    networkStatus: computeNetworkStatus()
)
private func computeNetworkStatus() -> NetworkStatus? {
    if remoteClient.connectionState.isConnected {
        let hostName: String
        if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" }
        return NetworkStatus(
            mode: .remote(hostName: hostName),
            onDisconnect: { [remoteClient] in remoteClient.disconnect() },
            onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) }
        )
    }
    if let server = hostServer, server.isHosting {
        return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName))
    }
    return nil
}
  • Step 7: Build and verify

Run: xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10

Expected: Build succeeds.

  • Step 8: Commit
git add Music/MusicApp.swift
git commit -m "feat(remote): wire HostServer and RemoteClient into MusicApp with menu items and DB swap"

Task 12: ConnectionSheet UI

Files:

  • Create: Music/Views/ConnectionSheet.swift

  • Step 1: Implement ConnectionSheet

// Music/Views/ConnectionSheet.swift
import SwiftUI

struct ConnectionSheet: View {
    var remoteClient: RemoteClient
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 16) {
            Text("Connect to Host")
                .font(.headline)

            if let message = remoteClient.connectionState.userMessage {
                HStack(spacing: 8) {
                    if !remoteClient.connectionState.isConnected &&
                        remoteClient.connectionState != .disconnected {
                        ProgressView().controlSize(.small)
                    }
                    Text(message)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }

            if case .connectionLost(let reason) = remoteClient.connectionState {
                VStack(spacing: 8) {
                    Text(reason).font(.caption).foregroundStyle(.red)
                    Button("Retry") { remoteClient.startDiscovery() }
                }
            }

            if remoteClient.discoveredHosts.isEmpty && remoteClient.connectionState == .discovering {
                VStack(spacing: 8) {
                    ProgressView()
                    Text("Looking for hosts on your network...")
                        .font(.caption).foregroundStyle(.secondary)
                }
                .frame(height: 100)
            } else {
                List(remoteClient.discoveredHosts, id: \.name) { host in
                    HStack {
                        Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
                        Text(host.name)
                        Spacer()
                        Button("Connect") { remoteClient.connect(to: host) }
                            .buttonStyle(.borderedProminent).controlSize(.small)
                    }
                    .padding(.vertical, 4)
                }
                .frame(minHeight: 100, maxHeight: 200)
            }

            Button("Cancel") {
                remoteClient.stopDiscovery()
                isPresented = false
            }
            .keyboardShortcut(.cancelAction)
        }
        .padding(20)
        .frame(width: 380)
        .onChange(of: remoteClient.connectionState) { _, newState in
            if case .connected = newState { isPresented = false }
        }
    }
}
  • Step 2: Commit
git add Music/Views/ConnectionSheet.swift
git commit -m "feat(remote): add ConnectionSheet for discovering and connecting to hosts"

Task 13: ContentView — Banner, Disable Writes, Host Indicator

Files:

  • Modify: Music/ContentView.swift

  • Modify: Music/Views/PlaylistBarView.swift

  • Step 1: Add networkStatus property to ContentView

Replace the audio property with:

var networkStatus: NetworkStatus?
  • Step 2: Add network status banners

Insert right after the opening VStack(spacing: 0) { in body:

if let status = networkStatus {
    switch status.mode {
    case .remote(let hostName):
        HStack(spacing: 8) {
            Image(systemName: "antenna.radiowaves.left.and.right")
                .font(.system(size: 10)).foregroundStyle(.blue)
            Text("Connected to \(hostName)")
                .font(.system(size: 11, weight: .medium)).foregroundStyle(.blue)
            Spacer()
            Button("Refresh") { status.onRefreshLibrary?() }
                .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
            Button("Disconnect") { status.onDisconnect?() }
                .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
        }
        .padding(.horizontal, 12).padding(.vertical, 4)
        .background(Color.blue.opacity(0.08))
    case .hosting(let remoteName):
        HStack(spacing: 8) {
            Image(systemName: "antenna.radiowaves.left.and.right")
                .font(.system(size: 10)).foregroundStyle(.green)
            Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting")
                .font(.system(size: 11, weight: .medium)).foregroundStyle(.green)
            Spacer()
        }
        .padding(.horizontal, 12).padding(.vertical, 4)
        .background(Color.green.opacity(0.08))
    }
}
  • Step 3: Disable playlist context menus in remote mode

In PlaylistBarView.swift, add property:

var isRemoteMode: Bool = false

Wrap the context menu content:

.contextMenu {
    if !isRemoteMode {
        Button("Rename...") { onRename(item) }
        if let smart = item as? SmartPlaylist {
            Button("Edit Search Query...") { onEditQuery(smart) }
        }
        Button("Delete") { onDelete(item) }
    }
}
  • Step 4: Pass isRemoteMode to PlaylistBarView

In ContentView.swift, update the PlaylistBarView(...) call:

PlaylistBarView(
    playlists: playlist.allPlaylists,
    selectedItem: showHome ? nil : playlist.selectedItem,
    isHomeSelected: showHome,
    isRemoteMode: networkStatus?.isRemoteMode ?? false,
    // ... rest unchanged
  • Step 5: Build and verify

Run: xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10

Expected: Build succeeds.

  • Step 6: Commit
git add Music/ContentView.swift Music/Views/PlaylistBarView.swift
git commit -m "feat(remote): add network status banners, disable write actions in remote mode"

Task 14: Run All Tests

Files: None — verification only.

  • Step 1: Run the full test suite

Run: xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Test Suite|Test Case|Executed|FAIL)'

Expected: All tests pass — existing tests unchanged, new tests (RemoteProtocol, ConnectionState, NDJSONTransport, HostServerIntegration) pass.

  • Step 2: Fix any failures and commit

Task 15: Manual Testing

  • Step 1: Test Host Mode
  1. Launch app → File → "Enable Host Mode" → verify toggle works
  2. Open Console.app, filter "com.music.remote" → verify "Host started on port XXXX"
  3. Verify green "Hosting" banner appears
  4. Toggle off → verify "Host stopped" and banner disappears
  • Step 2: Test Remote Connection
  1. On second machine, launch app → File → "Connect to Remote..."
  2. Verify host appears in list → click "Connect"
  3. Verify DB downloads, blue "Connected to [name]" banner appears
  4. Verify library shows host's tracks
  • Step 3: Test Remote Playback
  1. Double-click a track → audio plays on host, not remote
  2. Play/pause, next, previous, seek, volume, shuffle all work
  3. Now-playing indicator updates on remote
  • Step 4: Test Error Handling
  1. Click "Disconnect" → returns to local mode cleanly
  2. Kill host app mid-playback → "Connection lost" appears on remote
  3. Check Console.app for clear diagnostic logs at every step