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

2263 lines
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**
```swift
// 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**
```swift
// 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**
```bash
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**
```swift
// 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**
```bash
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**
```swift
// 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**
```swift
// 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**
```bash
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**
```swift
// 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**
```swift
// 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**
```bash
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`:
```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:
```swift
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**
```bash
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`:
```swift
var onPlaybackStateChanged: (() -> Void)?
```
Fire it from the periodic time observer (inside the existing closure, after updating `currentTime` and `duration`):
```swift
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:
```swift
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`:
```swift
private var remoteClient: RemoteClient?
var trackResolver: ((Int64) -> Track?)?
private var isRemote: Bool { remoteClient != nil }
```
At the end of `init`, add:
```swift
audio.onPlaybackStateChanged = { [weak self] in
self?.syncFromAudio()
}
```
Add the sync method:
```swift
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`:
```swift
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:
```swift
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()`:
```swift
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()`:
```swift
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()`:
```swift
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:
```swift
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**
```swift
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**
```bash
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:
```swift
// DELETE: var audio: AudioService
```
Replace `playerControls`:
```swift
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()`:
```swift
// DELETE: .onChange(of: audio.currentTime) { _, _ in
// player.checkHalfway()
// }
```
Update `installKeyboardMonitor` — remove `audio` from capture list, replace `audio.togglePlayPause()` with `player.togglePlayPause()`:
```swift
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**
```bash
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**
```swift
// 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**
```swift
// 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**
```bash
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**
```swift
// 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**
```bash
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**
```swift
// 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**
```bash
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`:
```swift
@State private var hostServer: HostServer?
@State private var remoteClient = RemoteClient()
@State private var showConnectionSheet = false
```
- [ ] **Step 2: Add menu items**
Replace the `.commands { ... }` block:
```swift
.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(...)`:
```swift
.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**
```swift
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**
```swift
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:
```swift
ContentView(
library: library,
player: player,
scanner: scanner,
playlist: playlist,
shazam: shazamService,
db: db,
showNewPlaylistAlert: $showNewPlaylistAlert,
networkStatus: computeNetworkStatus()
)
```
```swift
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**
```bash
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**
```swift
// 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**
```bash
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:
```swift
var networkStatus: NetworkStatus?
```
- [ ] **Step 2: Add network status banners**
Insert right after the opening `VStack(spacing: 0) {` in `body`:
```swift
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:
```swift
var isRemoteMode: Bool = false
```
Wrap the context menu content:
```swift
.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:
```swift
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**
```bash
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