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.
2263 lines
76 KiB
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
|
|
|