Compare commits
No commits in common. 'db3873b29eaa011ee111da89f16bb223ef18d2a5' and 'd20bb2fef45479e048776a9870bf9d8f8e775d00' have entirely different histories.
db3873b29e
...
d20bb2fef4
@ -1,69 +0,0 @@ |
|||||||
import Foundation |
|
||||||
|
|
||||||
/// Represents the connection lifecycle of the remote client. |
|
||||||
/// Transitions are validated by `canTransition(to:)` to enforce a strict state machine. |
|
||||||
nonisolated enum ConnectionState: Equatable, Sendable { |
|
||||||
case disconnected |
|
||||||
case discovering |
|
||||||
case foundHost(String) |
|
||||||
case downloadingDB |
|
||||||
case connectingCommandChannel |
|
||||||
case connected(String) |
|
||||||
case connectionLost(String) |
|
||||||
|
|
||||||
/// Human-readable status message for display in the UI. |
|
||||||
var userMessage: String? { |
|
||||||
switch self { |
|
||||||
case .disconnected: |
|
||||||
nil |
|
||||||
case .discovering: |
|
||||||
"Searching for hosts..." |
|
||||||
case .foundHost(let host): |
|
||||||
"Found \(host)" |
|
||||||
case .downloadingDB: |
|
||||||
"Downloading library..." |
|
||||||
case .connectingCommandChannel: |
|
||||||
"Connecting..." |
|
||||||
case .connected(let host): |
|
||||||
"Connected to \(host)" |
|
||||||
case .connectionLost(let reason): |
|
||||||
"Connection lost — \(reason)" |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Whether the client is fully connected. |
|
||||||
var isConnected: Bool { |
|
||||||
if case .connected = self { return true } |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
/// Validates whether transitioning from the current state to `next` is allowed. |
|
||||||
/// |
|
||||||
/// Valid forward transitions follow the connection lifecycle: |
|
||||||
/// disconnected → discovering → foundHost → downloadingDB → connectingCommandChannel → connected → connectionLost → discovering |
|
||||||
/// |
|
||||||
/// Any state may transition to `.disconnected` (the user can always disconnect). |
|
||||||
func canTransition(to next: ConnectionState) -> Bool { |
|
||||||
// Any state can go to disconnected |
|
||||||
if case .disconnected = next { return true } |
|
||||||
|
|
||||||
switch self { |
|
||||||
case .disconnected: |
|
||||||
if case .discovering = next { return true } |
|
||||||
case .discovering: |
|
||||||
if case .foundHost = next { return true } |
|
||||||
case .foundHost: |
|
||||||
if case .downloadingDB = next { return true } |
|
||||||
case .downloadingDB: |
|
||||||
if case .connectingCommandChannel = next { return true } |
|
||||||
case .connectingCommandChannel: |
|
||||||
if case .connected = next { return true } |
|
||||||
case .connected: |
|
||||||
if case .connectionLost = next { return true } |
|
||||||
case .connectionLost: |
|
||||||
if case .discovering = next { return true } |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,363 +0,0 @@ |
|||||||
import Foundation |
|
||||||
import Network |
|
||||||
import os |
|
||||||
|
|
||||||
@MainActor |
|
||||||
@Observable |
|
||||||
final class HostServer { |
|
||||||
var isHosting = false |
|
||||||
var connectedRemoteName: String? |
|
||||||
private(set) var actualPort: UInt16? |
|
||||||
|
|
||||||
private let dbPath: String |
|
||||||
private var listener: NWListener? |
|
||||||
private var commandTransport: NDJSONTransport? |
|
||||||
private var commandConnection: NWConnection? |
|
||||||
private var player: PlayerViewModel? |
|
||||||
private var db: DatabaseService? |
|
||||||
private var stateTimer: Timer? |
|
||||||
|
|
||||||
private let logger = RemoteLogger.host |
|
||||||
|
|
||||||
init(dbPath: String) { |
|
||||||
self.dbPath = dbPath |
|
||||||
} |
|
||||||
|
|
||||||
/// Configure the server with a player and database for command dispatch. |
|
||||||
/// Pass `nil` for either if not needed (e.g. DB-only serving without a player). |
|
||||||
func configure(player: PlayerViewModel?, db: DatabaseService?) { |
|
||||||
self.player = player |
|
||||||
self.db = db |
|
||||||
} |
|
||||||
|
|
||||||
/// Start the Bonjour listener on a random TCP port. |
|
||||||
func start() throws { |
|
||||||
let params = NWParameters.tcp |
|
||||||
let listener = try NWListener(using: params) |
|
||||||
listener.service = NWListener.Service(type: "_musicremote._tcp") |
|
||||||
|
|
||||||
listener.stateUpdateHandler = { [weak self] state in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
switch state { |
|
||||||
case .ready: |
|
||||||
if let port = listener.port?.rawValue { |
|
||||||
self.actualPort = port |
|
||||||
self.logger.info("Listener ready on port \(port)") |
|
||||||
} |
|
||||||
self.isHosting = true |
|
||||||
case .failed(let error): |
|
||||||
self.logger.error("Listener failed: \(error.localizedDescription)") |
|
||||||
self.isHosting = false |
|
||||||
case .cancelled: |
|
||||||
self.isHosting = false |
|
||||||
default: |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
listener.newConnectionHandler = { [weak self] connection in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
self?.handleNewConnection(connection) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
listener.start(queue: .main) |
|
||||||
self.listener = listener |
|
||||||
|
|
||||||
// Start periodic playback-state timer |
|
||||||
stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
self?.sendPlaybackState() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Stop the server, close all connections, and remove the Bonjour advertisement. |
|
||||||
func stop() { |
|
||||||
stateTimer?.invalidate() |
|
||||||
stateTimer = nil |
|
||||||
commandTransport?.close() |
|
||||||
commandTransport = nil |
|
||||||
commandConnection?.cancel() |
|
||||||
commandConnection = nil |
|
||||||
connectedRemoteName = nil |
|
||||||
listener?.cancel() |
|
||||||
listener = nil |
|
||||||
actualPort = nil |
|
||||||
isHosting = false |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Connection Handling |
|
||||||
|
|
||||||
/// Receive the first chunk of data from a new connection to determine the HTTP route. |
|
||||||
private func handleNewConnection(_ connection: NWConnection) { |
|
||||||
connection.stateUpdateHandler = { [weak self] state in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
if case .failed(let error) = state { |
|
||||||
self?.logger.error("Connection failed: \(error.localizedDescription)") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
connection.start(queue: .main) |
|
||||||
|
|
||||||
// Read the initial HTTP request line to determine the route |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, _, error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
|
|
||||||
if let error { |
|
||||||
self.logger.error("Failed to read request: \(error.localizedDescription)") |
|
||||||
connection.cancel() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
guard let data, let request = String(data: data, encoding: .utf8) else { |
|
||||||
connection.cancel() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
self.routeRequest(request, on: connection) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Parse the HTTP request line and dispatch to the appropriate handler. |
|
||||||
private func routeRequest(_ request: String, on connection: NWConnection) { |
|
||||||
let firstLine = request.split(separator: "\r\n").first.map(String.init) ?? request |
|
||||||
logger.info("Request: \(firstLine)") |
|
||||||
|
|
||||||
if firstLine.hasPrefix("GET /db") { |
|
||||||
handleDBRequest(on: connection) |
|
||||||
} else if firstLine.hasPrefix("GET /cmd") { |
|
||||||
handleCommandRequest(on: connection) |
|
||||||
} else { |
|
||||||
sendHTTP(status: "404 Not Found", body: Data("Not Found".utf8), on: connection, close: true) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - GET /db |
|
||||||
|
|
||||||
/// Serve the SQLite database as an HTTP response. |
|
||||||
/// Uses SQLite's backup API to produce a self-contained copy that includes |
|
||||||
/// all WAL data, avoiding races with concurrent writers. |
|
||||||
private func handleDBRequest(on connection: NWConnection) { |
|
||||||
do { |
|
||||||
let data: Data |
|
||||||
if let db { |
|
||||||
// Create a temporary copy via the backup API so the served file |
|
||||||
// is self-contained (no WAL/SHM dependency) and consistent. |
|
||||||
let tempURL = FileManager.default.temporaryDirectory |
|
||||||
.appendingPathComponent(UUID().uuidString + ".sqlite") |
|
||||||
defer { try? FileManager.default.removeItem(at: tempURL) } |
|
||||||
try db.backup(to: tempURL.path) |
|
||||||
data = try Data(contentsOf: tempURL) |
|
||||||
} else { |
|
||||||
// Fallback: serve the raw file when no DatabaseService is configured |
|
||||||
data = try Data(contentsOf: URL(fileURLWithPath: dbPath)) |
|
||||||
} |
|
||||||
logger.info("Serving database (\(data.count) bytes)") |
|
||||||
sendHTTP( |
|
||||||
status: "200 OK", |
|
||||||
body: data, |
|
||||||
contentType: "application/octet-stream", |
|
||||||
on: connection, |
|
||||||
close: true |
|
||||||
) |
|
||||||
} catch { |
|
||||||
logger.error("Failed to read database: \(error.localizedDescription)") |
|
||||||
sendHTTP( |
|
||||||
status: "500 Internal Server Error", |
|
||||||
body: Data("Failed to read database".utf8), |
|
||||||
on: connection, |
|
||||||
close: true |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - GET /cmd |
|
||||||
|
|
||||||
/// Upgrade the connection to an NDJSON command channel. |
|
||||||
private func handleCommandRequest(on connection: NWConnection) { |
|
||||||
// Only one command channel at a time |
|
||||||
if commandTransport != nil { |
|
||||||
logger.warning("Rejecting second command channel") |
|
||||||
sendHTTP( |
|
||||||
status: "409 Conflict", |
|
||||||
body: Data("Command channel already connected".utf8), |
|
||||||
on: connection, |
|
||||||
close: true |
|
||||||
) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Send the HTTP 200 response header, then upgrade to NDJSON streaming |
|
||||||
let header = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n" |
|
||||||
connection.send(content: Data(header.utf8), completion: .contentProcessed { [weak self] error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
|
|
||||||
if let error { |
|
||||||
self.logger.error("Failed to send cmd header: \(error.localizedDescription)") |
|
||||||
connection.cancel() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
self.setupCommandTransport(on: connection) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/// Wire up the NDJSON transport for reading commands and sending events. |
|
||||||
private func setupCommandTransport(on connection: NWConnection) { |
|
||||||
let transport = NDJSONTransport(connection: connection, logger: logger) |
|
||||||
self.commandTransport = transport |
|
||||||
self.commandConnection = connection |
|
||||||
|
|
||||||
transport.onLine = { [weak self] line in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
self?.handleIncomingLine(line) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
transport.onClose = { [weak self] in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
self.logger.info("Command channel closed") |
|
||||||
self.commandTransport = nil |
|
||||||
self.commandConnection = nil |
|
||||||
self.connectedRemoteName = nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
transport.startReceiving() |
|
||||||
logger.info("Command channel established") |
|
||||||
|
|
||||||
// Send initial playback state |
|
||||||
sendPlaybackState() |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Command Dispatch |
|
||||||
|
|
||||||
/// Process an incoming NDJSON line — try handshake first, then remote command. |
|
||||||
private func handleIncomingLine(_ line: String) { |
|
||||||
guard let data = line.data(using: .utf8) else { return } |
|
||||||
let decoder = JSONDecoder() |
|
||||||
|
|
||||||
// Try parsing as a handshake message first |
|
||||||
if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) { |
|
||||||
logger.info("Handshake received: v\(handshake.protocolVersion), app \(handshake.appVersion)") |
|
||||||
connectedRemoteName = handshake.appVersion |
|
||||||
sendPlaybackState() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Parse as a remote command |
|
||||||
do { |
|
||||||
let command = try decoder.decode(RemoteCommand.self, from: data) |
|
||||||
logger.info("Received command: \(String(describing: command))") |
|
||||||
dispatchCommand(command) |
|
||||||
} catch { |
|
||||||
logger.error("Failed to decode command: \(error.localizedDescription)") |
|
||||||
commandTransport?.send(HostEvent.error(message: "Invalid command")) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Execute the remote command against the player. |
|
||||||
private func dispatchCommand(_ command: RemoteCommand) { |
|
||||||
guard let player else { |
|
||||||
logger.warning("No player configured, ignoring command") |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
switch command { |
|
||||||
case .play(let trackId, let queueIds): |
|
||||||
handlePlayCommand(trackId: trackId, queueIds: queueIds, player: player) |
|
||||||
case .pause: |
|
||||||
player.pause() |
|
||||||
case .resume: |
|
||||||
player.resume() |
|
||||||
case .next: |
|
||||||
player.next() |
|
||||||
case .previous: |
|
||||||
player.previous() |
|
||||||
case .seek(let position): |
|
||||||
player.seek(to: position) |
|
||||||
case .setVolume(let level): |
|
||||||
player.setVolume(level) |
|
||||||
case .toggleShuffle: |
|
||||||
player.toggleShuffle() |
|
||||||
case .refreshDB: |
|
||||||
commandTransport?.send(HostEvent.dbReady) |
|
||||||
return // Don't send playback state for refreshDB, just the dbReady event |
|
||||||
} |
|
||||||
|
|
||||||
// After each command, send current playback state |
|
||||||
sendPlaybackState() |
|
||||||
} |
|
||||||
|
|
||||||
/// Handle the play command by fetching tracks and setting up the queue. |
|
||||||
private func handlePlayCommand(trackId: Int64, queueIds: [Int64], player: PlayerViewModel) { |
|
||||||
guard let db else { |
|
||||||
logger.warning("No database configured, cannot handle play command") |
|
||||||
commandTransport?.send(HostEvent.error(message: "No database available")) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
do { |
|
||||||
let tracks = try db.fetchTracksByIds(queueIds) |
|
||||||
guard let track = tracks.first(where: { $0.id == trackId }) else { |
|
||||||
logger.warning("Track \(trackId) not found in database") |
|
||||||
commandTransport?.send(HostEvent.error(message: "Track not found")) |
|
||||||
return |
|
||||||
} |
|
||||||
player.setQueue(tracks) |
|
||||||
player.play(track) |
|
||||||
} catch { |
|
||||||
logger.error("Failed to fetch tracks: \(error.localizedDescription)") |
|
||||||
commandTransport?.send(HostEvent.error(message: "Database error")) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - State Updates |
|
||||||
|
|
||||||
/// Build and send the current playback state to the connected remote. |
|
||||||
private func sendPlaybackState() { |
|
||||||
guard let transport = commandTransport else { return } |
|
||||||
|
|
||||||
let payload = PlaybackStatePayload( |
|
||||||
trackId: player?.currentTrack?.id, |
|
||||||
isPlaying: player?.isPlaying ?? false, |
|
||||||
currentTime: player?.currentTime ?? 0, |
|
||||||
duration: player?.duration ?? 0, |
|
||||||
volume: player?.volume ?? 0.65, |
|
||||||
isShuffled: player?.isShuffled ?? false |
|
||||||
) |
|
||||||
|
|
||||||
transport.send(HostEvent.playbackState(payload)) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - HTTP Helper |
|
||||||
|
|
||||||
/// Send an HTTP response with the given status, body, and content type. |
|
||||||
private func sendHTTP( |
|
||||||
status: String, |
|
||||||
body: Data?, |
|
||||||
contentType: String = "text/plain", |
|
||||||
on connection: NWConnection, |
|
||||||
close: Bool |
|
||||||
) { |
|
||||||
let bodyData = body ?? Data() |
|
||||||
let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyData.count)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n" |
|
||||||
var responseData = Data(header.utf8) |
|
||||||
responseData.append(bodyData) |
|
||||||
|
|
||||||
connection.send(content: responseData, completion: .contentProcessed { _ in |
|
||||||
if close { |
|
||||||
connection.cancel() |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
import Foundation |
|
||||||
|
|
||||||
/// A pure, testable line-buffered parser. |
|
||||||
/// Receives arbitrary Data chunks, splits on newlines, emits complete lines via callback. |
|
||||||
final class NDJSONLineBuffer: @unchecked Sendable { |
|
||||||
private var buffer = "" |
|
||||||
private let onLine: (String) -> Void |
|
||||||
|
|
||||||
init(onLine: @escaping (String) -> Void) { |
|
||||||
self.onLine = onLine |
|
||||||
} |
|
||||||
|
|
||||||
/// Append data to the internal buffer and emit every complete line (delimited by `\n`). |
|
||||||
/// Partial lines are retained until the next `feed()` call completes them. |
|
||||||
func feed(_ data: Data) { |
|
||||||
guard let chunk = String(data: data, encoding: .utf8) else { return } |
|
||||||
buffer.append(chunk) |
|
||||||
|
|
||||||
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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Clear the internal buffer, discarding any incomplete line. |
|
||||||
func reset() { |
|
||||||
buffer = "" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,88 +0,0 @@ |
|||||||
import Foundation |
|
||||||
import Network |
|
||||||
import os |
|
||||||
|
|
||||||
/// Wraps an `NWConnection` for sending and receiving newline-delimited JSON messages. |
|
||||||
@MainActor |
|
||||||
final class NDJSONTransport { |
|
||||||
private let connection: NWConnection |
|
||||||
private let lineBuffer: NDJSONLineBuffer |
|
||||||
private let logger: os.Logger |
|
||||||
private let encoder: JSONEncoder = { |
|
||||||
let e = JSONEncoder() |
|
||||||
e.outputFormatting = [.sortedKeys] |
|
||||||
return e |
|
||||||
}() |
|
||||||
|
|
||||||
/// Called for each complete line received from the connection. |
|
||||||
var onLine: ((String) -> Void)? |
|
||||||
|
|
||||||
/// Called when the connection is closed or encounters an error. |
|
||||||
var onClose: (() -> Void)? |
|
||||||
|
|
||||||
init(connection: NWConnection, logger: os.Logger) { |
|
||||||
self.connection = connection |
|
||||||
self.logger = logger |
|
||||||
|
|
||||||
// Capture self weakly in the line buffer callback to avoid retain cycles. |
|
||||||
// The buffer is created before self is fully initialized, so we set the |
|
||||||
// actual forwarding closure after init completes via a two-phase approach. |
|
||||||
var forwardLine: ((String) -> Void)? |
|
||||||
self.lineBuffer = NDJSONLineBuffer { line in |
|
||||||
forwardLine?(line) |
|
||||||
} |
|
||||||
forwardLine = { [weak self] line in |
|
||||||
self?.onLine?(line) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// JSON-encode the message, append a newline, and send it over the connection. |
|
||||||
func send<T: Encodable>(_ message: T) { |
|
||||||
do { |
|
||||||
var data = try encoder.encode(message) |
|
||||||
data.append(contentsOf: [UInt8(ascii: "\n")]) |
|
||||||
connection.send(content: data, completion: .contentProcessed { [weak self] error in |
|
||||||
if let error { |
|
||||||
self?.logger.error("Send failed: \(error.localizedDescription)") |
|
||||||
} |
|
||||||
}) |
|
||||||
} catch { |
|
||||||
logger.error("Encode failed: \(error.localizedDescription)") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Begin the receive loop. Data is fed into the line buffer, which emits |
|
||||||
/// complete lines via the `onLine` callback. |
|
||||||
func startReceiving() { |
|
||||||
receiveNext() |
|
||||||
} |
|
||||||
|
|
||||||
/// Cancel the underlying connection. |
|
||||||
func close() { |
|
||||||
connection.cancel() |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Private |
|
||||||
|
|
||||||
private func receiveNext() { |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] content, _, isComplete, error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
|
|
||||||
if let data = content, !data.isEmpty { |
|
||||||
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() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
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 |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,393 +0,0 @@ |
|||||||
import Foundation |
|
||||||
import Network |
|
||||||
import os |
|
||||||
|
|
||||||
@MainActor |
|
||||||
@Observable |
|
||||||
final class RemoteClient: RemoteCommandSender { |
|
||||||
|
|
||||||
// MARK: - Public State |
|
||||||
|
|
||||||
var connectionState = ConnectionState.disconnected |
|
||||||
var discoveredHosts: [(name: String, endpoint: NWEndpoint)] = [] |
|
||||||
var onPlaybackState: ((PlaybackStatePayload) -> Void)? |
|
||||||
var onDBReady: (() -> Void)? |
|
||||||
|
|
||||||
// MARK: - Private State |
|
||||||
|
|
||||||
private var browser: NWBrowser? |
|
||||||
private var commandTransport: NDJSONTransport? |
|
||||||
private var hostEndpoint: NWEndpoint? |
|
||||||
private var pingTimer: Timer? |
|
||||||
private var missedPings = 0 |
|
||||||
|
|
||||||
private let logger = RemoteLogger.client |
|
||||||
|
|
||||||
// MARK: - Remote DB Path |
|
||||||
|
|
||||||
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 |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Discovery |
|
||||||
|
|
||||||
/// Start scanning for `_musicremote._tcp` services on the local network. |
|
||||||
func startDiscovery() { |
|
||||||
let descriptor = NWBrowser.Descriptor.bonjour(type: "_musicremote._tcp", domain: nil) |
|
||||||
let browser = NWBrowser(for: descriptor, using: .tcp) |
|
||||||
|
|
||||||
browser.stateUpdateHandler = { [weak self] state in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
switch state { |
|
||||||
case .ready: |
|
||||||
self.logger.info("Browser ready") |
|
||||||
case .failed(let error): |
|
||||||
self.logger.error("Browser failed: \(error.localizedDescription)") |
|
||||||
case .cancelled: |
|
||||||
self.logger.info("Browser cancelled") |
|
||||||
default: |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
browser.browseResultsChangedHandler = { [weak self] results, _ in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
self.discoveredHosts = results.compactMap { result in |
|
||||||
if case .service(let name, _, _, _) = result.endpoint { |
|
||||||
return (name: name, endpoint: result.endpoint) |
|
||||||
} |
|
||||||
return nil |
|
||||||
} |
|
||||||
self.logger.info("Discovered \(self.discoveredHosts.count) host(s)") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
browser.start(queue: .main) |
|
||||||
self.browser = browser |
|
||||||
transition(to: .discovering) |
|
||||||
} |
|
||||||
|
|
||||||
/// Stop scanning and clear discovered hosts. |
|
||||||
func stopDiscovery() { |
|
||||||
browser?.cancel() |
|
||||||
browser = nil |
|
||||||
discoveredHosts = [] |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Connection |
|
||||||
|
|
||||||
/// Connect to a discovered host: download its DB, then establish the command channel. |
|
||||||
func connect(to host: (name: String, endpoint: NWEndpoint)) { |
|
||||||
stopDiscovery() |
|
||||||
hostEndpoint = host.endpoint |
|
||||||
transition(to: .foundHost(host.name)) |
|
||||||
transition(to: .downloadingDB) |
|
||||||
downloadDatabase(on: host.endpoint, hostName: host.name) |
|
||||||
} |
|
||||||
|
|
||||||
/// Tear down everything: command channel, timers, and the local remote DB file. |
|
||||||
func disconnect() { |
|
||||||
pingTimer?.invalidate() |
|
||||||
pingTimer = nil |
|
||||||
missedPings = 0 |
|
||||||
commandTransport?.close() |
|
||||||
commandTransport = nil |
|
||||||
hostEndpoint = nil |
|
||||||
stopDiscovery() |
|
||||||
deleteRemoteDB() |
|
||||||
transition(to: .disconnected) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - RemoteCommandSender |
|
||||||
|
|
||||||
func sendCommand(_ command: RemoteCommand) { |
|
||||||
guard let transport = commandTransport else { |
|
||||||
logger.warning("Cannot send command — no command channel") |
|
||||||
return |
|
||||||
} |
|
||||||
transport.send(command) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - DB Download |
|
||||||
|
|
||||||
/// Open a TCP connection to the host, send `GET /db`, and save the response body to disk. |
|
||||||
private func downloadDatabase(on endpoint: NWEndpoint, hostName: String) { |
|
||||||
let connection = NWConnection(to: endpoint, using: .tcp) |
|
||||||
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
switch state { |
|
||||||
case .ready: |
|
||||||
self.logger.info("DB connection ready") |
|
||||||
self.sendDBRequest(on: connection, hostName: hostName) |
|
||||||
case .failed(let error): |
|
||||||
self.logger.error("DB connection failed: \(error.localizedDescription)") |
|
||||||
self.transition(to: .disconnected) |
|
||||||
default: |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
connection.start(queue: .main) |
|
||||||
} |
|
||||||
|
|
||||||
/// Send the HTTP GET request for the database. |
|
||||||
private func sendDBRequest(on connection: NWConnection, hostName: String) { |
|
||||||
let request = "GET /db HTTP/1.1\r\nHost: \(hostName)\r\nConnection: close\r\n\r\n" |
|
||||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
if let error { |
|
||||||
self.logger.error("Failed to send DB request: \(error.localizedDescription)") |
|
||||||
self.transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
self.receiveDBResponse(on: connection, accumulated: Data(), hostName: hostName) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/// Accumulate the full HTTP response for the DB download, then strip headers and save. |
|
||||||
private func receiveDBResponse(on connection: NWConnection, accumulated: Data, hostName: String) { |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 1_048_576) { [weak self] data, _, isComplete, error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
|
|
||||||
var buffer = accumulated |
|
||||||
if let data { |
|
||||||
buffer.append(data) |
|
||||||
} |
|
||||||
|
|
||||||
if isComplete || error != nil { |
|
||||||
// Done receiving — extract body after HTTP headers |
|
||||||
connection.cancel() |
|
||||||
self.handleDBData(buffer, hostName: hostName) |
|
||||||
} else { |
|
||||||
// Keep reading |
|
||||||
self.receiveDBResponse(on: connection, accumulated: buffer, hostName: hostName) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Strip the HTTP headers from the response and write the SQLite body to disk. |
|
||||||
private func handleDBData(_ data: Data, hostName: String) { |
|
||||||
// Find the header/body separator: \r\n\r\n |
|
||||||
let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] |
|
||||||
guard let separatorRange = data.range(of: Data(separator)) else { |
|
||||||
logger.error("DB response missing HTTP header separator") |
|
||||||
transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
let body = data[separatorRange.upperBound...] |
|
||||||
guard !body.isEmpty else { |
|
||||||
logger.error("DB response body is empty") |
|
||||||
transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Ensure the directory exists |
|
||||||
let dirURL = URL(fileURLWithPath: Self.remoteDBPath).deletingLastPathComponent() |
|
||||||
try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) |
|
||||||
|
|
||||||
do { |
|
||||||
try body.write(to: URL(fileURLWithPath: Self.remoteDBPath)) |
|
||||||
logger.info("Database saved (\(body.count) bytes) to \(Self.remoteDBPath)") |
|
||||||
connectCommandChannel(hostName: hostName) |
|
||||||
} catch { |
|
||||||
logger.error("Failed to write DB: \(error.localizedDescription)") |
|
||||||
transition(to: .disconnected) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Command Channel |
|
||||||
|
|
||||||
/// Open a second TCP connection to the same endpoint and upgrade it to the command channel. |
|
||||||
private func connectCommandChannel(hostName: String) { |
|
||||||
guard let endpoint = hostEndpoint else { |
|
||||||
logger.error("No endpoint stored for command channel") |
|
||||||
transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
transition(to: .connectingCommandChannel) |
|
||||||
|
|
||||||
let connection = NWConnection(to: endpoint, using: .tcp) |
|
||||||
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
switch state { |
|
||||||
case .ready: |
|
||||||
self.logger.info("Command channel TCP ready") |
|
||||||
self.sendCmdRequest(on: connection, hostName: hostName) |
|
||||||
case .failed(let error): |
|
||||||
self.logger.error("Command channel connection failed: \(error.localizedDescription)") |
|
||||||
self.transition(to: .disconnected) |
|
||||||
default: |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
connection.start(queue: .main) |
|
||||||
} |
|
||||||
|
|
||||||
/// Send the HTTP GET request to upgrade to the command channel. |
|
||||||
private func sendCmdRequest(on connection: NWConnection, hostName: String) { |
|
||||||
let request = "GET /cmd HTTP/1.1\r\nHost: \(hostName)\r\nConnection: keep-alive\r\n\r\n" |
|
||||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
if let error { |
|
||||||
self.logger.error("Failed to send cmd request: \(error.localizedDescription)") |
|
||||||
self.transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
self.receiveCmdHeader(on: connection, accumulated: Data(), hostName: hostName) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/// Read the HTTP 200 response header before upgrading to NDJSON streaming. |
|
||||||
private func receiveCmdHeader(on connection: NWConnection, accumulated: Data, hostName: String) { |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, _, error in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
|
|
||||||
if let error { |
|
||||||
self.logger.error("Failed to read cmd header: \(error.localizedDescription)") |
|
||||||
self.transition(to: .disconnected) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
var buffer = accumulated |
|
||||||
if let data { |
|
||||||
buffer.append(data) |
|
||||||
} |
|
||||||
|
|
||||||
// Check if we've received the full HTTP header |
|
||||||
let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] |
|
||||||
if buffer.range(of: Data(separator)) != nil { |
|
||||||
self.commandChannelReady(connection: connection, hostName: hostName) |
|
||||||
} else { |
|
||||||
// Keep reading until we get the full header |
|
||||||
self.receiveCmdHeader(on: connection, accumulated: buffer, hostName: hostName) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// The command channel HTTP handshake is complete — set up NDJSON transport. |
|
||||||
private func commandChannelReady(connection: NWConnection, hostName: String) { |
|
||||||
let transport = NDJSONTransport(connection: connection, logger: logger) |
|
||||||
self.commandTransport = transport |
|
||||||
|
|
||||||
transport.onLine = { [weak self] line in |
|
||||||
self?.handleEventLine(line) |
|
||||||
} |
|
||||||
|
|
||||||
transport.onClose = { [weak self] in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
self.logger.info("Command channel closed by host") |
|
||||||
self.pingTimer?.invalidate() |
|
||||||
self.pingTimer = nil |
|
||||||
self.commandTransport = nil |
|
||||||
if self.connectionState.isConnected { |
|
||||||
self.transition(to: .connectionLost("Host closed connection")) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
transport.startReceiving() |
|
||||||
|
|
||||||
// Send handshake |
|
||||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" |
|
||||||
let handshake = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: appVersion) |
|
||||||
transport.send(handshake) |
|
||||||
|
|
||||||
// Start keep-alive ping timer |
|
||||||
missedPings = 0 |
|
||||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in |
|
||||||
Task { @MainActor [weak self] in |
|
||||||
guard let self else { return } |
|
||||||
self.missedPings += 1 |
|
||||||
if self.missedPings >= 3 { |
|
||||||
self.logger.warning("Keep-alive timeout — \(self.missedPings) consecutive pings unanswered") |
|
||||||
self.pingTimer?.invalidate() |
|
||||||
self.pingTimer = nil |
|
||||||
self.commandTransport?.close() |
|
||||||
self.commandTransport = nil |
|
||||||
self.transition(to: .connectionLost("Host not responding")) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
transition(to: .connected(hostName)) |
|
||||||
logger.info("Command channel established with \(hostName)") |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Event Handling |
|
||||||
|
|
||||||
/// Parse an incoming NDJSON line as a `HostEvent` and dispatch to the appropriate callback. |
|
||||||
private func handleEventLine(_ line: String) { |
|
||||||
guard let data = line.data(using: .utf8) else { return } |
|
||||||
let decoder = JSONDecoder() |
|
||||||
|
|
||||||
do { |
|
||||||
let event = try decoder.decode(HostEvent.self, from: data) |
|
||||||
switch event { |
|
||||||
case .playbackState(let payload): |
|
||||||
missedPings = 0 |
|
||||||
onPlaybackState?(payload) |
|
||||||
case .dbReady: |
|
||||||
onDBReady?() |
|
||||||
case .error(let message): |
|
||||||
logger.error("Host error: \(message)") |
|
||||||
} |
|
||||||
} catch { |
|
||||||
logger.error("Failed to decode host event: \(error.localizedDescription)") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - State Machine |
|
||||||
|
|
||||||
/// Transition to a new connection state, validating via `canTransition(to:)`. |
|
||||||
private func transition(to newState: ConnectionState) { |
|
||||||
let oldState = connectionState |
|
||||||
guard oldState != newState else { return } |
|
||||||
|
|
||||||
guard oldState.canTransition(to: newState) else { |
|
||||||
logger.warning("Invalid transition: \(String(describing: oldState)) → \(String(describing: newState))") |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
logger.info("State: \(String(describing: oldState)) → \(String(describing: newState))") |
|
||||||
connectionState = newState |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Helpers |
|
||||||
|
|
||||||
/// Delete the local remote database file if it exists. |
|
||||||
private func deleteRemoteDB() { |
|
||||||
let path = Self.remoteDBPath |
|
||||||
if FileManager.default.fileExists(atPath: path) { |
|
||||||
do { |
|
||||||
try FileManager.default.removeItem(atPath: path) |
|
||||||
logger.info("Deleted remote DB at \(path)") |
|
||||||
} catch { |
|
||||||
logger.error("Failed to delete remote DB: \(error.localizedDescription)") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
import os |
|
||||||
|
|
||||||
enum RemoteLogger { |
|
||||||
static let host = Logger(subsystem: "com.music.remote", category: "host") |
|
||||||
static let client = Logger(subsystem: "com.music.remote", category: "client") |
|
||||||
} |
|
||||||
@ -1,172 +0,0 @@ |
|||||||
import Foundation |
|
||||||
|
|
||||||
// MARK: - Protocol Version |
|
||||||
|
|
||||||
/// Current version of the remote control wire protocol. |
|
||||||
nonisolated let RemoteProtocolVersion: Int = 1 |
|
||||||
|
|
||||||
// MARK: - Supporting Types |
|
||||||
|
|
||||||
/// Snapshot of the host's playback state, sent to remote clients. |
|
||||||
nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { |
|
||||||
var trackId: Int64? |
|
||||||
var isPlaying: Bool |
|
||||||
var currentTime: Double |
|
||||||
var duration: Double |
|
||||||
var volume: Float |
|
||||||
var isShuffled: Bool |
|
||||||
} |
|
||||||
|
|
||||||
/// Exchanged during connection setup to agree on protocol version. |
|
||||||
nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { |
|
||||||
var protocolVersion: Int |
|
||||||
var appVersion: String |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - RemoteCommand |
|
||||||
|
|
||||||
/// Commands sent from a remote client to the host. |
|
||||||
/// Wire format: `{"type":"<case>","payload":{...}}` (payload omitted for cases with no associated values). |
|
||||||
nonisolated enum RemoteCommand: 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 |
|
||||||
} |
|
||||||
|
|
||||||
extension RemoteCommand: Codable { |
|
||||||
private enum TypeKey: String, Codable { |
|
||||||
case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB |
|
||||||
} |
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey { |
|
||||||
case type, payload |
|
||||||
} |
|
||||||
|
|
||||||
// Payload structs for cases with associated values |
|
||||||
private struct PlayPayload: Codable { |
|
||||||
var trackId: Int64 |
|
||||||
var queueIds: [Int64] |
|
||||||
} |
|
||||||
|
|
||||||
private struct SeekPayload: Codable { |
|
||||||
var position: Double |
|
||||||
} |
|
||||||
|
|
||||||
private struct VolumePayload: Codable { |
|
||||||
var 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(TypeKey.play, forKey: .type) |
|
||||||
try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) |
|
||||||
case .pause: |
|
||||||
try container.encode(TypeKey.pause, forKey: .type) |
|
||||||
case .resume: |
|
||||||
try container.encode(TypeKey.resume, forKey: .type) |
|
||||||
case .next: |
|
||||||
try container.encode(TypeKey.next, forKey: .type) |
|
||||||
case .previous: |
|
||||||
try container.encode(TypeKey.previous, forKey: .type) |
|
||||||
case .seek(let position): |
|
||||||
try container.encode(TypeKey.seek, forKey: .type) |
|
||||||
try container.encode(SeekPayload(position: position), forKey: .payload) |
|
||||||
case .setVolume(let level): |
|
||||||
try container.encode(TypeKey.setVolume, forKey: .type) |
|
||||||
try container.encode(VolumePayload(level: level), forKey: .payload) |
|
||||||
case .toggleShuffle: |
|
||||||
try container.encode(TypeKey.toggleShuffle, forKey: .type) |
|
||||||
case .refreshDB: |
|
||||||
try container.encode(TypeKey.refreshDB, forKey: .type) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
init(from decoder: Decoder) throws { |
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
|
||||||
let type = try container.decode(TypeKey.self, forKey: .type) |
|
||||||
switch type { |
|
||||||
case .play: |
|
||||||
let payload = try container.decode(PlayPayload.self, forKey: .payload) |
|
||||||
self = .play(trackId: payload.trackId, queueIds: payload.queueIds) |
|
||||||
case .pause: |
|
||||||
self = .pause |
|
||||||
case .resume: |
|
||||||
self = .resume |
|
||||||
case .next: |
|
||||||
self = .next |
|
||||||
case .previous: |
|
||||||
self = .previous |
|
||||||
case .seek: |
|
||||||
let payload = try container.decode(SeekPayload.self, forKey: .payload) |
|
||||||
self = .seek(position: payload.position) |
|
||||||
case .setVolume: |
|
||||||
let payload = try container.decode(VolumePayload.self, forKey: .payload) |
|
||||||
self = .setVolume(level: payload.level) |
|
||||||
case .toggleShuffle: |
|
||||||
self = .toggleShuffle |
|
||||||
case .refreshDB: |
|
||||||
self = .refreshDB |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - HostEvent |
|
||||||
|
|
||||||
/// Events sent from the host to remote clients. |
|
||||||
/// Wire format: `{"type":"<case>","payload":{...}}` (payload omitted for cases with no associated values). |
|
||||||
nonisolated enum HostEvent: Equatable, Sendable { |
|
||||||
case playbackState(PlaybackStatePayload) |
|
||||||
case dbReady |
|
||||||
case error(message: String) |
|
||||||
} |
|
||||||
|
|
||||||
extension HostEvent: Codable { |
|
||||||
private enum TypeKey: String, Codable { |
|
||||||
case playbackState, dbReady, error |
|
||||||
} |
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey { |
|
||||||
case type, payload |
|
||||||
} |
|
||||||
|
|
||||||
private struct ErrorPayload: Codable { |
|
||||||
var message: String |
|
||||||
} |
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws { |
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self) |
|
||||||
switch self { |
|
||||||
case .playbackState(let payload): |
|
||||||
try container.encode(TypeKey.playbackState, forKey: .type) |
|
||||||
try container.encode(payload, forKey: .payload) |
|
||||||
case .dbReady: |
|
||||||
try container.encode(TypeKey.dbReady, forKey: .type) |
|
||||||
case .error(let message): |
|
||||||
try container.encode(TypeKey.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(TypeKey.self, forKey: .type) |
|
||||||
switch type { |
|
||||||
case .playbackState: |
|
||||||
let payload = try container.decode(PlaybackStatePayload.self, forKey: .payload) |
|
||||||
self = .playbackState(payload) |
|
||||||
case .dbReady: |
|
||||||
self = .dbReady |
|
||||||
case .error: |
|
||||||
let payload = try container.decode(ErrorPayload.self, forKey: .payload) |
|
||||||
self = .error(message: payload.message) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,65 +0,0 @@ |
|||||||
import SwiftUI |
|
||||||
import Network |
|
||||||
|
|
||||||
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 } |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,44 +0,0 @@ |
|||||||
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") |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,164 +0,0 @@ |
|||||||
import Testing |
|
||||||
import Foundation |
|
||||||
import Network |
|
||||||
@testable import Music |
|
||||||
|
|
||||||
@MainActor |
|
||||||
struct HostServerIntegrationTests { |
|
||||||
|
|
||||||
// Starts a HostServer, connects via TCP, sends GET /db, |
|
||||||
// verifies the response contains valid SQLite data. |
|
||||||
@Test(.timeLimit(.minutes(1))) |
|
||||||
func dbDownloadReturnsValidSQLite() async throws { |
|
||||||
// 1. Create a temp directory and a SQLite database with one track |
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
|
||||||
let dbPath = tempDir.appendingPathComponent("db.sqlite").path |
|
||||||
let db = try DatabaseService(path: dbPath) |
|
||||||
var track = Track.fixture(fileURL: "/test.mp3") |
|
||||||
try db.insert(&track) |
|
||||||
|
|
||||||
// 2. Start the HostServer (configured with db for WAL checkpoint) and wait for the listener to be ready |
|
||||||
let server = HostServer(dbPath: dbPath) |
|
||||||
server.configure(player: nil, db: db) |
|
||||||
try server.start() |
|
||||||
try await Task.sleep(for: .milliseconds(200)) |
|
||||||
let port = server.actualPort! |
|
||||||
|
|
||||||
// 3. Perform an HTTP GET /db request to download the database |
|
||||||
let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db") |
|
||||||
|
|
||||||
// 4. Verify the response starts with the SQLite magic header |
|
||||||
let header = String(data: responseData.prefix(16), encoding: .utf8) ?? "" |
|
||||||
#expect(header.hasPrefix("SQLite format 3")) |
|
||||||
|
|
||||||
// 5. Write the downloaded data to disk and verify it contains the inserted track |
|
||||||
let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path |
|
||||||
try responseData.write(to: URL(fileURLWithPath: downloadedPath)) |
|
||||||
let downloadedDb = try DatabaseService(path: downloadedPath) |
|
||||||
#expect(try downloadedDb.trackCount() == 1) |
|
||||||
|
|
||||||
// 6. Clean up |
|
||||||
server.stop() |
|
||||||
try? FileManager.default.removeItem(at: tempDir) |
|
||||||
} |
|
||||||
|
|
||||||
// Connects to /cmd, sends a pause command, verifies a playbackState event comes back. |
|
||||||
@Test(.timeLimit(.minutes(1))) |
|
||||||
func commandChannelRoundTrip() async throws { |
|
||||||
// 1. Create a temp directory and an empty SQLite database |
|
||||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
|
||||||
let dbPath = tempDir.appendingPathComponent("db.sqlite").path |
|
||||||
_ = try DatabaseService(path: dbPath) |
|
||||||
|
|
||||||
// 2. Set up the player and server with command dispatch configured |
|
||||||
let audio = AudioService() |
|
||||||
let player = PlayerViewModel(audio: audio, db: nil) |
|
||||||
let server = HostServer(dbPath: dbPath) |
|
||||||
server.configure(player: player, db: nil) |
|
||||||
try server.start() |
|
||||||
try await Task.sleep(for: .milliseconds(200)) |
|
||||||
let port = server.actualPort! |
|
||||||
|
|
||||||
// 3. Open a command channel connection via GET /cmd |
|
||||||
let connection = try await connectCommandChannel(host: "127.0.0.1", port: port) |
|
||||||
|
|
||||||
// 4. Send a pause command as NDJSON |
|
||||||
let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause) |
|
||||||
var lineData = pauseCmd |
|
||||||
lineData.append(contentsOf: "\n".utf8) |
|
||||||
connection.send(content: lineData, completion: .contentProcessed { _ in }) |
|
||||||
|
|
||||||
// 5. Wait for and decode the playbackState response event |
|
||||||
let responseLine = try await receiveOneLine(on: connection) |
|
||||||
let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8)) |
|
||||||
|
|
||||||
// 6. Verify it is a playbackState event with isPlaying == false |
|
||||||
if case .playbackState(let payload) = event { |
|
||||||
#expect(payload.isPlaying == false) |
|
||||||
} else { |
|
||||||
Issue.record("Expected playbackState, got \(event)") |
|
||||||
} |
|
||||||
|
|
||||||
// 7. Clean up |
|
||||||
connection.cancel() |
|
||||||
server.stop() |
|
||||||
try? FileManager.default.removeItem(at: tempDir) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Helpers |
|
||||||
|
|
||||||
/// Performs a simple HTTP GET using NWConnection and returns the response body. |
|
||||||
private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { |
|
||||||
try await withCheckedThrowingContinuation { continuation in |
|
||||||
let connection = NWConnection( |
|
||||||
host: NWEndpoint.Host(host), |
|
||||||
port: NWEndpoint.Port(rawValue: port)!, |
|
||||||
using: .tcp |
|
||||||
) |
|
||||||
connection.stateUpdateHandler = { state in |
|
||||||
if case .ready = state { |
|
||||||
// Send the HTTP request |
|
||||||
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" |
|
||||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) |
|
||||||
// Receive the full response (Connection: close ensures we get everything) |
|
||||||
connection.receiveMessage { data, _, _, error in |
|
||||||
if let error { |
|
||||||
continuation.resume(throwing: error) |
|
||||||
} else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { |
|
||||||
// Strip HTTP headers, return just the body |
|
||||||
continuation.resume(returning: Data(data[range.upperBound...])) |
|
||||||
} else { |
|
||||||
continuation.resume(returning: data ?? Data()) |
|
||||||
} |
|
||||||
connection.cancel() |
|
||||||
} |
|
||||||
} else if case .failed(let error) = state { |
|
||||||
continuation.resume(throwing: error) |
|
||||||
} |
|
||||||
} |
|
||||||
connection.start(queue: .main) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Opens a TCP connection to the /cmd endpoint and waits for the HTTP response header. |
|
||||||
private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection { |
|
||||||
try await withCheckedThrowingContinuation { continuation in |
|
||||||
let connection = NWConnection( |
|
||||||
host: NWEndpoint.Host(host), |
|
||||||
port: NWEndpoint.Port(rawValue: port)!, |
|
||||||
using: .tcp |
|
||||||
) |
|
||||||
connection.stateUpdateHandler = { state in |
|
||||||
if case .ready = state { |
|
||||||
// Send the HTTP upgrade request for the command channel |
|
||||||
let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n" |
|
||||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) |
|
||||||
// Consume the HTTP 200 response header |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in |
|
||||||
continuation.resume(returning: connection) |
|
||||||
} |
|
||||||
} else if case .failed(let error) = state { |
|
||||||
continuation.resume(throwing: error) |
|
||||||
} |
|
||||||
} |
|
||||||
connection.start(queue: .main) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Reads one newline-delimited line from a connection. |
|
||||||
private func receiveOneLine(on connection: NWConnection) async throws -> String { |
|
||||||
try await withCheckedThrowingContinuation { continuation in |
|
||||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in |
|
||||||
if let error { |
|
||||||
continuation.resume(throwing: error) |
|
||||||
} else { |
|
||||||
let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" |
|
||||||
// Extract the first complete line from the received data |
|
||||||
continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
import Testing |
|
||||||
import Foundation |
|
||||||
@testable import Music |
|
||||||
|
|
||||||
struct NDJSONTransportTests { |
|
||||||
|
|
||||||
// Verifies that feed() correctly splits newline-delimited input into individual lines. |
|
||||||
@Test func splitsLinesCorrectly() { |
|
||||||
var lines: [String] = [] |
|
||||||
let buffer = NDJSONLineBuffer { lines.append($0) } |
|
||||||
buffer.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 buffer = NDJSONLineBuffer { lines.append($0) } |
|
||||||
buffer.feed(Data("{\"type\":\"pa".utf8)) |
|
||||||
#expect(lines.isEmpty) |
|
||||||
buffer.feed(Data("use\"}\n".utf8)) |
|
||||||
#expect(lines == ["{\"type\":\"pause\"}"]) |
|
||||||
} |
|
||||||
|
|
||||||
// Verifies empty lines are ignored. |
|
||||||
@Test func ignoresEmptyLines() { |
|
||||||
var lines: [String] = [] |
|
||||||
let buffer = NDJSONLineBuffer { lines.append($0) } |
|
||||||
buffer.feed(Data("\n\n{\"type\":\"next\"}\n\n".utf8)) |
|
||||||
#expect(lines == ["{\"type\":\"next\"}"]) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,132 +0,0 @@ |
|||||||
import Foundation |
|
||||||
import Testing |
|
||||||
@testable import Music |
|
||||||
|
|
||||||
struct RemoteProtocolTests { |
|
||||||
private let encoder: JSONEncoder = { |
|
||||||
let e = JSONEncoder() |
|
||||||
e.outputFormatting = [.sortedKeys] |
|
||||||
return e |
|
||||||
}() |
|
||||||
private let decoder = JSONDecoder() |
|
||||||
|
|
||||||
// MARK: - Helpers |
|
||||||
|
|
||||||
/// Encode then decode a value, returning the decoded copy. |
|
||||||
private func roundTrip<T: Codable>(_ value: T) throws -> T { |
|
||||||
let data = try encoder.encode(value) |
|
||||||
return try decoder.decode(T.self, from: data) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - RemoteCommand round-trip tests |
|
||||||
// Each test encodes a RemoteCommand case to JSON and decodes it back, |
|
||||||
// verifying the decoded value equals the original. |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_play() throws { |
|
||||||
let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_pause() throws { |
|
||||||
let cmd = RemoteCommand.pause |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_resume() throws { |
|
||||||
let cmd = RemoteCommand.resume |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_next() throws { |
|
||||||
let cmd = RemoteCommand.next |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_previous() throws { |
|
||||||
let cmd = RemoteCommand.previous |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_seek() throws { |
|
||||||
let cmd = RemoteCommand.seek(position: 123.456) |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_setVolume() throws { |
|
||||||
let cmd = RemoteCommand.setVolume(level: 0.75) |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_toggleShuffle() throws { |
|
||||||
let cmd = RemoteCommand.toggleShuffle |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func remoteCommandRoundTrip_refreshDB() throws { |
|
||||||
let cmd = RemoteCommand.refreshDB |
|
||||||
#expect(try roundTrip(cmd) == cmd) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - HostEvent round-trip tests |
|
||||||
// Each test encodes a HostEvent case to JSON and decodes it back, |
|
||||||
// verifying the decoded value equals the original. |
|
||||||
|
|
||||||
@Test func hostEventRoundTrip_playbackState() throws { |
|
||||||
let payload = PlaybackStatePayload( |
|
||||||
trackId: 7, |
|
||||||
isPlaying: true, |
|
||||||
currentTime: 42.5, |
|
||||||
duration: 210.0, |
|
||||||
volume: 0.8, |
|
||||||
isShuffled: false |
|
||||||
) |
|
||||||
let event = HostEvent.playbackState(payload) |
|
||||||
#expect(try roundTrip(event) == event) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func hostEventRoundTrip_dbReady() throws { |
|
||||||
let event = HostEvent.dbReady |
|
||||||
#expect(try roundTrip(event) == event) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func hostEventRoundTrip_error() throws { |
|
||||||
let event = HostEvent.error(message: "Something went wrong") |
|
||||||
#expect(try roundTrip(event) == event) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - HandshakeMessage round-trip test |
|
||||||
// Verifies HandshakeMessage survives JSON encoding and decoding. |
|
||||||
|
|
||||||
@Test func handshakeMessageRoundTrip() throws { |
|
||||||
let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.2.3") |
|
||||||
#expect(try roundTrip(msg) == msg) |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Wire format decode tests |
|
||||||
// Verify that hand-crafted JSON strings matching the expected wire format |
|
||||||
// decode correctly, ensuring the Codable implementation matches the spec. |
|
||||||
|
|
||||||
@Test func wireFormatDecode_playCommand() throws { |
|
||||||
let json = """ |
|
||||||
{"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} |
|
||||||
""" |
|
||||||
let decoded = try decoder.decode(RemoteCommand.self, from: Data(json.utf8)) |
|
||||||
#expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) |
|
||||||
} |
|
||||||
|
|
||||||
@Test func wireFormatDecode_playbackStateEvent() throws { |
|
||||||
let json = """ |
|
||||||
{"type":"playbackState","payload":{"trackId":7,"isPlaying":true,"currentTime":42.5,"duration":210.0,"volume":0.8,"isShuffled":false}} |
|
||||||
""" |
|
||||||
let decoded = try decoder.decode(HostEvent.self, from: Data(json.utf8)) |
|
||||||
let expected = HostEvent.playbackState(PlaybackStatePayload( |
|
||||||
trackId: 7, |
|
||||||
isPlaying: true, |
|
||||||
currentTime: 42.5, |
|
||||||
duration: 210.0, |
|
||||||
volume: 0.8, |
|
||||||
isShuffled: false |
|
||||||
)) |
|
||||||
#expect(decoded == expected) |
|
||||||
} |
|
||||||
} |
|
||||||
Loading…
Reference in new issue