Compare commits

..

17 Commits

Author SHA1 Message Date
Laurent db3873b29e pbxproj update 1 month ago
Laurent d17dac287d feat(remote): add network status banners, disable write actions in remote mode 1 month ago
Laurent 4b256f7811 feat(remote): wire HostServer and RemoteClient into MusicApp with menu items and DB swap 1 month ago
Laurent 4ee0325d2f feat(remote): add ConnectionSheet for discovering and connecting to hosts 1 month ago
Laurent fb76901366 feat(remote): add NetworkStatus model for View-layer network state 1 month ago
Laurent 4bf1a5e4ef feat(remote): add RemoteClient with Bonjour discovery, DB download, NDJSON command channel 1 month ago
Laurent 463ecb518b feat(remote): add HostServer with Bonjour, HTTP DB download, and NDJSON command channel 1 month ago
Laurent e5b5c249b4 refactor: remove AudioService from ContentView — all playback state via PlayerViewModel 1 month ago
Laurent 4fa431e9bd refactor: make PlayerViewModel single source of truth for all playback state 1 month ago
Laurent c3b97eb201 feat(remote): add DatabaseService.fetchTracksByIds for efficient ID-based lookups 1 month ago
Laurent c754858f21 feat(remote): add NDJSONTransport for line-buffered JSON framing over TCP 1 month ago
Laurent b0359f127b feat(remote): add ConnectionState machine with transition validation 1 month ago
Laurent 89d50e6e94 feat(remote): add structured os.Logger for host and client 1 month ago
Laurent 6005ce739f feat(remote): add RemoteCommand and HostEvent protocol types with tests 1 month ago
Laurent 5320da4b82 chore: declare app does not use non-exempt encryption 1 month ago
Laurent 0eaa095bae feat: add Search MP3 action to Shazam identification alert 1 month ago
Laurent 2f1b9b537c fix: stabilize layout at minimum window height and refine player controls 1 month ago
  1. 6
      Music.xcodeproj/project.pbxproj
  2. 247
      Music/ContentView.swift
  3. 2
      Music/Music.entitlements
  4. 98
      Music/MusicApp.swift
  5. 69
      Music/Remote/ConnectionState.swift
  6. 363
      Music/Remote/HostServer.swift
  7. 32
      Music/Remote/NDJSONLineBuffer.swift
  8. 88
      Music/Remote/NDJSONTransport.swift
  9. 17
      Music/Remote/NetworkStatus.swift
  10. 393
      Music/Remote/RemoteClient.swift
  11. 6
      Music/Remote/RemoteLogger.swift
  12. 172
      Music/Remote/RemoteProtocol.swift
  13. 7
      Music/Services/AudioService.swift
  14. 23
      Music/Services/DatabaseService.swift
  15. 155
      Music/ViewModels/PlayerViewModel.swift
  16. 65
      Music/Views/ConnectionSheet.swift
  17. 155
      Music/Views/PlayerControlsView.swift
  18. 11
      Music/Views/PlaylistBarView.swift
  19. 44
      MusicTests/ConnectionStateTests.swift
  20. 20
      MusicTests/DatabaseServiceTests.swift
  21. 164
      MusicTests/HostServerIntegrationTests.swift
  22. 32
      MusicTests/NDJSONTransportTests.swift
  23. 132
      MusicTests/RemoteProtocolTests.swift

@ -427,7 +427,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@ -435,6 +435,7 @@
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";
@ -467,7 +468,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@ -475,6 +476,7 @@
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";

@ -5,11 +5,11 @@ struct ContentView: View {
var library: LibraryViewModel
var player: PlayerViewModel
var scanner: ScannerService
var audio: AudioService
var playlist: PlaylistViewModel
var shazam: ShazamService
var db: DatabaseService
@Binding var showNewPlaylistAlert: Bool
var networkStatus: NetworkStatus?
@State private var showRenameAlert = false
@State private var showEditQueryAlert = false
@State private var playlistNameInput = ""
@ -26,6 +26,35 @@ struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
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))
}
}
SearchBarView(
searchText: $searchText,
trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount,
@ -60,95 +89,99 @@ struct ContentView: View {
.padding(.vertical, 4)
}
if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) {
Button(action: {
playlist.deselectPlaylist()
searchText = ""
showHome = false
}) {
HStack(spacing: 2) {
Image(systemName: "chevron.left")
.font(.system(size: 10))
Text("Library")
.font(.system(size: 12))
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) {
Button(action: {
playlist.deselectPlaylist()
searchText = ""
showHome = false
}) {
HStack(spacing: 2) {
Image(systemName: "chevron.left")
.font(.system(size: 10))
Text("Library")
.font(.system(size: 12))
}
.foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.buttonStyle(.plain)
Text("/")
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text("/")
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
if showHome && playlist.selectedItem == nil {
HomeView(
recentTracks: recentTracks,
trackCount: library.trackCount,
totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in
player.setQueue(recentTracks)
player.play(track)
},
onShowAll: {
showHome = false
}
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
if showHome && playlist.selectedItem == nil {
HomeView(
recentTracks: recentTracks,
trackCount: library.trackCount,
totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in
player.setQueue(recentTracks)
player.play(track)
},
onShowAll: {
showHome = false
}
},
onDoubleClick: { track in
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList)
player.play(track)
},
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
}
},
onDoubleClick: { track in
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList)
player.play(track)
},
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
}
}
.frame(maxHeight: .infinity)
PlaylistBarView(
playlists: playlist.allPlaylists,
selectedItem: showHome ? nil : playlist.selectedItem,
isHomeSelected: showHome,
isRemoteMode: networkStatus?.isRemoteMode ?? false,
onHomeSelect: {
if showHome {
showHome = false
@ -201,9 +234,6 @@ struct ContentView: View {
handleDrop(providers)
return true
}
.onChange(of: audio.currentTime) { _, _ in
player.checkHalfway()
}
.onChange(of: library.trackCount) { _, _ in
if showHome { loadHomeData() }
}
@ -254,7 +284,18 @@ struct ContentView: View {
get: { shazam.matchedTitle != nil },
set: { if !$0 { shazam.clearResult() } }
)) {
Button("OK") { shazam.clearResult() }
Button("Search MP3") {
let query = [shazam.matchedTitle, shazam.matchedArtist]
.compactMap { $0 }
.joined(separator: " ")
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(query, forType: .string)
if let url = URL(string: "https://freemp3music.org/") {
NSWorkspace.shared.open(url)
}
shazam.clearResult()
}
Button("Close", role: .cancel) { shazam.clearResult() }
} message: {
if let title = shazam.matchedTitle {
Text("\(title)\(shazam.matchedArtist ?? "Unknown Artist")")
@ -275,26 +316,36 @@ struct ContentView: View {
private var playerControls: some View {
PlayerControlsView(
currentTrack: player.currentTrack,
isPlaying: audio.isPlaying,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume,
isPlaying: player.isPlaying,
currentTime: player.currentTime,
duration: player.duration,
volume: player.volume,
isShuffled: player.isShuffled,
onPlayPause: { audio.togglePlayPause() },
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: { audio.seek(to: $0) },
onScrubStart: { audio.beginScrubbing() },
onScrub: { audio.scrub(to: $0) },
onScrubEnd: { audio.endScrubbing(at: $0) },
onVolumeChange: { audio.volume = $0 },
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() }
)
}
private func installKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player] event in
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [player, library, playlist] event in
guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else {
return event
}
@ -304,7 +355,15 @@ struct ContentView: View {
}
switch event.keyCode {
case 49: // space
audio.togglePlayPause()
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()

@ -12,6 +12,8 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>

@ -11,6 +11,9 @@ struct MusicApp: App {
@State private var playlistVM: PlaylistViewModel?
@State private var showNewPlaylistAlert = false
@State private var initError: String?
@State private var hostServer: HostServer?
@State private var remoteClient = RemoteClient()
@State private var showConnectionSheet = false
var body: some Scene {
WindowGroup {
@ -24,11 +27,11 @@ struct MusicApp: App {
library: library,
player: player,
scanner: scanner,
audio: audioService,
playlist: playlist,
shazam: shazamService,
db: db,
showNewPlaylistAlert: $showNewPlaylistAlert
showNewPlaylistAlert: $showNewPlaylistAlert,
networkStatus: computeNetworkStatus()
)
} else if let error = initError {
Text("Failed to initialize database: \(error)")
@ -39,6 +42,16 @@ struct MusicApp: App {
}
}
.frame(minWidth: 800, minHeight: 500)
.onChange(of: remoteClient.connectionState) { _, newState in
if case .connected = newState {
enterRemoteMode()
} else if newState == .disconnected {
exitRemoteMode()
}
}
.sheet(isPresented: $showConnectionSheet) {
ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet)
}
}
.commands {
CommandGroup(after: .newItem) {
@ -46,11 +59,27 @@ struct MusicApp: App {
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)
}
}
}
@ -145,4 +174,69 @@ struct MusicApp: App {
return nil
}
}
// MARK: - Remote / Host
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)")
}
}
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 = { 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)
}
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
}
}

@ -0,0 +1,69 @@
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
}
}

@ -0,0 +1,363 @@
import Foundation
import Network
import os
@MainActor
@Observable
final class HostServer {
var isHosting = false
var connectedRemoteName: String?
private(set) var actualPort: UInt16?
private let dbPath: String
private var listener: NWListener?
private var commandTransport: NDJSONTransport?
private var commandConnection: NWConnection?
private var player: PlayerViewModel?
private var db: DatabaseService?
private var stateTimer: Timer?
private let logger = RemoteLogger.host
init(dbPath: String) {
self.dbPath = dbPath
}
/// Configure the server with a player and database for command dispatch.
/// Pass `nil` for either if not needed (e.g. DB-only serving without a player).
func configure(player: PlayerViewModel?, db: DatabaseService?) {
self.player = player
self.db = db
}
/// Start the Bonjour listener on a random TCP port.
func start() throws {
let params = NWParameters.tcp
let listener = try NWListener(using: params)
listener.service = NWListener.Service(type: "_musicremote._tcp")
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor [weak self] in
guard let self else { return }
switch state {
case .ready:
if let port = listener.port?.rawValue {
self.actualPort = port
self.logger.info("Listener ready on port \(port)")
}
self.isHosting = true
case .failed(let error):
self.logger.error("Listener failed: \(error.localizedDescription)")
self.isHosting = false
case .cancelled:
self.isHosting = false
default:
break
}
}
}
listener.newConnectionHandler = { [weak self] connection in
Task { @MainActor [weak self] in
self?.handleNewConnection(connection)
}
}
listener.start(queue: .main)
self.listener = listener
// Start periodic playback-state timer
stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.sendPlaybackState()
}
}
}
/// Stop the server, close all connections, and remove the Bonjour advertisement.
func stop() {
stateTimer?.invalidate()
stateTimer = nil
commandTransport?.close()
commandTransport = nil
commandConnection?.cancel()
commandConnection = nil
connectedRemoteName = nil
listener?.cancel()
listener = nil
actualPort = nil
isHosting = false
}
// MARK: - Connection Handling
/// Receive the first chunk of data from a new connection to determine the HTTP route.
private func handleNewConnection(_ connection: NWConnection) {
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor [weak self] in
if case .failed(let error) = state {
self?.logger.error("Connection failed: \(error.localizedDescription)")
}
}
}
connection.start(queue: .main)
// Read the initial HTTP request line to determine the route
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, _, error in
Task { @MainActor [weak self] in
guard let self else { return }
if let error {
self.logger.error("Failed to read request: \(error.localizedDescription)")
connection.cancel()
return
}
guard let data, let request = String(data: data, encoding: .utf8) else {
connection.cancel()
return
}
self.routeRequest(request, on: connection)
}
}
}
/// Parse the HTTP request line and dispatch to the appropriate handler.
private func routeRequest(_ request: String, on connection: NWConnection) {
let firstLine = request.split(separator: "\r\n").first.map(String.init) ?? request
logger.info("Request: \(firstLine)")
if firstLine.hasPrefix("GET /db") {
handleDBRequest(on: connection)
} else if firstLine.hasPrefix("GET /cmd") {
handleCommandRequest(on: connection)
} else {
sendHTTP(status: "404 Not Found", body: Data("Not Found".utf8), on: connection, close: true)
}
}
// MARK: - GET /db
/// Serve the SQLite database as an HTTP response.
/// Uses SQLite's backup API to produce a self-contained copy that includes
/// all WAL data, avoiding races with concurrent writers.
private func handleDBRequest(on connection: NWConnection) {
do {
let data: Data
if let db {
// Create a temporary copy via the backup API so the served file
// is self-contained (no WAL/SHM dependency) and consistent.
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".sqlite")
defer { try? FileManager.default.removeItem(at: tempURL) }
try db.backup(to: tempURL.path)
data = try Data(contentsOf: tempURL)
} else {
// Fallback: serve the raw file when no DatabaseService is configured
data = try Data(contentsOf: URL(fileURLWithPath: dbPath))
}
logger.info("Serving database (\(data.count) bytes)")
sendHTTP(
status: "200 OK",
body: data,
contentType: "application/octet-stream",
on: connection,
close: true
)
} catch {
logger.error("Failed to read database: \(error.localizedDescription)")
sendHTTP(
status: "500 Internal Server Error",
body: Data("Failed to read database".utf8),
on: connection,
close: true
)
}
}
// MARK: - GET /cmd
/// Upgrade the connection to an NDJSON command channel.
private func handleCommandRequest(on connection: NWConnection) {
// Only one command channel at a time
if commandTransport != nil {
logger.warning("Rejecting second command channel")
sendHTTP(
status: "409 Conflict",
body: Data("Command channel already connected".utf8),
on: connection,
close: true
)
return
}
// Send the HTTP 200 response header, then upgrade to NDJSON streaming
let header = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n"
connection.send(content: Data(header.utf8), completion: .contentProcessed { [weak self] error in
Task { @MainActor [weak self] in
guard let self else { return }
if let error {
self.logger.error("Failed to send cmd header: \(error.localizedDescription)")
connection.cancel()
return
}
self.setupCommandTransport(on: connection)
}
})
}
/// Wire up the NDJSON transport for reading commands and sending events.
private func setupCommandTransport(on connection: NWConnection) {
let transport = NDJSONTransport(connection: connection, logger: logger)
self.commandTransport = transport
self.commandConnection = connection
transport.onLine = { [weak self] line in
Task { @MainActor [weak self] in
self?.handleIncomingLine(line)
}
}
transport.onClose = { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.logger.info("Command channel closed")
self.commandTransport = nil
self.commandConnection = nil
self.connectedRemoteName = nil
}
}
transport.startReceiving()
logger.info("Command channel established")
// Send initial playback state
sendPlaybackState()
}
// MARK: - Command Dispatch
/// Process an incoming NDJSON line try handshake first, then remote command.
private func handleIncomingLine(_ line: String) {
guard let data = line.data(using: .utf8) else { return }
let decoder = JSONDecoder()
// Try parsing as a handshake message first
if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) {
logger.info("Handshake received: v\(handshake.protocolVersion), app \(handshake.appVersion)")
connectedRemoteName = handshake.appVersion
sendPlaybackState()
return
}
// Parse as a remote command
do {
let command = try decoder.decode(RemoteCommand.self, from: data)
logger.info("Received command: \(String(describing: command))")
dispatchCommand(command)
} catch {
logger.error("Failed to decode command: \(error.localizedDescription)")
commandTransport?.send(HostEvent.error(message: "Invalid command"))
}
}
/// Execute the remote command against the player.
private func dispatchCommand(_ command: RemoteCommand) {
guard let player else {
logger.warning("No player configured, ignoring command")
return
}
switch command {
case .play(let trackId, let queueIds):
handlePlayCommand(trackId: trackId, queueIds: queueIds, player: player)
case .pause:
player.pause()
case .resume:
player.resume()
case .next:
player.next()
case .previous:
player.previous()
case .seek(let position):
player.seek(to: position)
case .setVolume(let level):
player.setVolume(level)
case .toggleShuffle:
player.toggleShuffle()
case .refreshDB:
commandTransport?.send(HostEvent.dbReady)
return // Don't send playback state for refreshDB, just the dbReady event
}
// After each command, send current playback state
sendPlaybackState()
}
/// Handle the play command by fetching tracks and setting up the queue.
private func handlePlayCommand(trackId: Int64, queueIds: [Int64], player: PlayerViewModel) {
guard let db else {
logger.warning("No database configured, cannot handle play command")
commandTransport?.send(HostEvent.error(message: "No database available"))
return
}
do {
let tracks = try db.fetchTracksByIds(queueIds)
guard let track = tracks.first(where: { $0.id == trackId }) else {
logger.warning("Track \(trackId) not found in database")
commandTransport?.send(HostEvent.error(message: "Track not found"))
return
}
player.setQueue(tracks)
player.play(track)
} catch {
logger.error("Failed to fetch tracks: \(error.localizedDescription)")
commandTransport?.send(HostEvent.error(message: "Database error"))
}
}
// MARK: - State Updates
/// Build and send the current playback state to the connected remote.
private func sendPlaybackState() {
guard let transport = commandTransport else { return }
let payload = PlaybackStatePayload(
trackId: player?.currentTrack?.id,
isPlaying: player?.isPlaying ?? false,
currentTime: player?.currentTime ?? 0,
duration: player?.duration ?? 0,
volume: player?.volume ?? 0.65,
isShuffled: player?.isShuffled ?? false
)
transport.send(HostEvent.playbackState(payload))
}
// MARK: - HTTP Helper
/// Send an HTTP response with the given status, body, and content type.
private func sendHTTP(
status: String,
body: Data?,
contentType: String = "text/plain",
on connection: NWConnection,
close: Bool
) {
let bodyData = body ?? Data()
let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyData.count)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n"
var responseData = Data(header.utf8)
responseData.append(bodyData)
connection.send(content: responseData, completion: .contentProcessed { _ in
if close {
connection.cancel()
}
})
}
}

@ -0,0 +1,32 @@
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 = ""
}
}

@ -0,0 +1,88 @@
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()
}
}
}
}
}

@ -0,0 +1,17 @@
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
}
}

@ -0,0 +1,393 @@
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)")
}
}
}
}

@ -0,0 +1,6 @@
import os
enum RemoteLogger {
static let host = Logger(subsystem: "com.music.remote", category: "host")
static let client = Logger(subsystem: "com.music.remote", category: "client")
}

@ -0,0 +1,172 @@
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)
}
}
}

@ -25,6 +25,7 @@ final class AudioService {
private var pendingSeekTime: Double?
var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
func play(url: URL) {
cleanup()
@ -42,6 +43,7 @@ final class AudioService {
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds
}
self.onPlaybackStateChanged?()
}
endObserver = NotificationCenter.default.addObserver(
@ -51,21 +53,25 @@ final class AudioService {
) { [weak self] _ in
self?.isPlaying = false
self?.currentTime = 0
self?.onPlaybackStateChanged?()
self?.onTrackFinished?()
}
player?.play()
isPlaying = true
onPlaybackStateChanged?()
}
func pause() {
player?.pause()
isPlaying = false
onPlaybackStateChanged?()
}
func resume() {
player?.play()
isPlaying = true
onPlaybackStateChanged?()
}
func togglePlayPause() {
@ -144,6 +150,7 @@ final class AudioService {
isPlaying = false
currentTime = 0
duration = 0
onPlaybackStateChanged?()
}
private func cleanup() {

@ -113,6 +113,15 @@ nonisolated final class DatabaseService: Sendable {
try migrator.migrate(db)
}
// MARK: - Maintenance
/// Create a self-contained copy of the database at the given path using
/// SQLite's online backup API. The copy includes all WAL data and is safe
/// to serve or transfer without additional files.
func backup(to destinationPath: String) throws {
try dbPool.backup(to: DatabaseQueue(path: destinationPath))
}
// MARK: - Write
func insert(_ track: inout Track) throws {
@ -201,6 +210,20 @@ nonisolated final class DatabaseService: Sendable {
}
}
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] }
}
func fetchRecentlyAdded(limit: Int) throws -> [Track] {
try dbPool.read { db in
try Track.fetchAll(

@ -1,11 +1,19 @@
import Foundation
import Observation
protocol RemoteCommandSender: AnyObject {
func sendCommand(_ command: RemoteCommand)
}
@Observable
final class PlayerViewModel {
var currentTrack: Track?
var currentIndex: Int?
var isShuffled = false
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
@ -13,6 +21,11 @@ final class PlayerViewModel {
private let db: DatabaseService?
private var halfwayReported = false
private var remoteClient: RemoteCommandSender?
var trackResolver: ((Int64) -> Track?)?
private var isRemote: Bool { remoteClient != nil }
init(audio: AudioService, db: DatabaseService?) {
self.audio = audio
self.db = db
@ -20,8 +33,26 @@ final class PlayerViewModel {
audio.onTrackFinished = { [weak self] in
self?.trackDidFinish()
}
audio.onPlaybackStateChanged = { [weak self] in
self?.syncFromAudio()
}
}
// MARK: - Audio Sync
private func syncFromAudio() {
guard !isRemote else { return }
isPlaying = audio.isPlaying
if !audio.isScrubbing {
currentTime = audio.currentTime
}
duration = audio.duration
checkHalfway()
}
// MARK: - Queue Management
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
if isShuffled {
@ -34,28 +65,81 @@ final class PlayerViewModel {
}
}
// MARK: - Playback Controls
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)
}
}
func togglePlayPause() {
if isPlaying { pause() } else { resume() }
}
func pause() {
isPlaying = false
if let client = remoteClient { client.sendCommand(.pause) } else { audio.pause() }
}
func resume() {
isPlaying = true
if let client = remoteClient { client.sendCommand(.resume) } else { audio.resume() }
}
func seek(to position: Double) {
currentTime = position
if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.seek(to: position) }
}
guard let url = URL(string: track.fileURL) else { return }
audio.play(url: url)
func setVolume(_ level: Float) {
volume = level
if let client = remoteClient { client.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 let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) }
}
func next() {
if let client = remoteClient {
client.sendCommand(.next)
return
}
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
audio.stop()
currentTrack = nil
currentIndex = nil
stop()
}
}
func previous() {
if let client = remoteClient {
client.sendCommand(.previous)
return
}
guard let idx = currentIndex else { return }
let prevIdx = max(0, idx - 1)
play(queue[prevIdx])
@ -63,6 +147,10 @@ final class PlayerViewModel {
func toggleShuffle() {
isShuffled.toggle()
if let client = remoteClient {
client.sendCommand(.toggleShuffle)
return
}
if isShuffled {
queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack)
} else {
@ -73,10 +161,62 @@ final class PlayerViewModel {
}
}
func stop() {
isPlaying = false
currentTime = 0
duration = 0
currentTrack = nil
currentIndex = nil
if !isRemote { audio.stop() }
}
// MARK: - Remote Mode
func enterRemoteMode(client: RemoteCommandSender) {
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
}
}
// MARK: - Internal
func checkHalfway() {
guard !halfwayReported,
audio.duration > 0,
audio.currentTime >= audio.duration * 0.5,
duration > 0,
currentTime >= duration * 0.5,
let track = currentTrack,
let trackId = track.id else { return }
@ -86,6 +226,7 @@ final class PlayerViewModel {
}
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())

@ -0,0 +1,65 @@
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 }
}
}
}

@ -22,18 +22,21 @@ struct PlayerControlsView: View {
@State private var dragValue: Double = 0
var body: some View {
HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
progressTrack
HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
transportSection
.frame(maxWidth: .infinity)
transportSection
.frame(maxWidth: .infinity)
volumeSection
.frame(maxWidth: .infinity, alignment: .trailing)
volumeSection
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
@ -75,74 +78,94 @@ struct PlayerControlsView: View {
}
}
private var transportSection: some View {
VStack(spacing: 4) {
HStack(spacing: 20) {
Button(action: onShuffleToggle) {
Image(systemName: "shuffle")
.font(.system(size: 12))
.foregroundStyle(isShuffled ? .blue : .secondary)
private var progressTrack: some View {
let trackHeight: CGFloat = 4
let thumbWidth: CGFloat = 4
let thumbHeight: CGFloat = 12
let displayedTime = isDragging ? dragValue : currentTime
let maxDuration = max(duration, 1)
let fraction = displayedTime / maxDuration
return VStack(spacing: 2) {
GeometryReader { geo in
let trackWidth = geo.size.width
ZStack(alignment: .leading) {
Rectangle()
.fill(.quaternary)
.frame(height: trackHeight)
Rectangle()
.fill(.blue)
.frame(width: trackWidth * fraction, height: trackHeight)
RoundedRectangle(cornerRadius: 1)
.fill(.blue)
.frame(width: thumbWidth, height: thumbHeight)
.offset(x: trackWidth * fraction - thumbWidth / 2)
}
.buttonStyle(.plain)
Button(action: onPrevious) {
Image(systemName: "backward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Spacer()
.frame(width: 12)
}
HStack(spacing: 8) {
Text(Self.formatTime(isDragging ? dragValue : currentTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .trailing)
Slider(
value: Binding(
get: { isDragging ? dragValue : currentTime },
set: { newValue in
dragValue = newValue
if isDragging {
onScrub(newValue)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
if !isDragging {
isDragging = true
dragValue = currentTime
onScrubStart()
}
dragValue = newValue
onScrub(newValue)
}
),
in: 0...max(duration, 1),
onEditingChanged: { editing in
if editing {
isDragging = true
dragValue = currentTime
onScrubStart()
} else {
onScrubEnd(dragValue)
.onEnded { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
onScrubEnd(newValue)
isDragging = false
}
}
)
.controlSize(.small)
}
.frame(height: thumbHeight)
HStack {
Text(Self.formatTime(displayedTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .leading)
}
.padding(.horizontal, 8)
}
}
private var transportSection: some View {
HStack(spacing: 20) {
Button(action: onShuffleToggle) {
Image(systemName: "shuffle")
.font(.system(size: 12))
.foregroundStyle(isShuffled ? .blue : .secondary)
}
.buttonStyle(.plain)
Button(action: onPrevious) {
Image(systemName: "backward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
}
.frame(maxWidth: 400)
}

@ -4,6 +4,7 @@ struct PlaylistBarView: View {
var playlists: [any PlaylistRepresentable]
var selectedItem: (any PlaylistRepresentable)?
var isHomeSelected: Bool
var isRemoteMode: Bool = false
var onHomeSelect: () -> Void
var onSelect: (any PlaylistRepresentable) -> Void
var onDeselect: () -> Void
@ -35,11 +36,13 @@ struct PlaylistBarView: View {
}
)
.contextMenu {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
}
Button("Delete") { onDelete(item) }
}
Button("Delete") { onDelete(item) }
}
}
}

@ -0,0 +1,44 @@
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")
}
}

@ -264,6 +264,26 @@ struct DatabaseServiceTests {
#expect(total == 0)
}
// 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)
}
// Inserts tracks in different months and verifies fetchMonthlyAdditions returns
// the correct per-month counts covering the requested range including empty months.
// Uses a UTC calendar to match the implementation, which uses UTC month boundaries

@ -0,0 +1,164 @@
import Testing
import Foundation
import Network
@testable import Music
@MainActor
struct HostServerIntegrationTests {
// Starts a HostServer, connects via TCP, sends GET /db,
// verifies the response contains valid SQLite data.
@Test(.timeLimit(.minutes(1)))
func dbDownloadReturnsValidSQLite() async throws {
// 1. Create a temp directory and a SQLite database with one track
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let dbPath = tempDir.appendingPathComponent("db.sqlite").path
let db = try DatabaseService(path: dbPath)
var track = Track.fixture(fileURL: "/test.mp3")
try db.insert(&track)
// 2. Start the HostServer (configured with db for WAL checkpoint) and wait for the listener to be ready
let server = HostServer(dbPath: dbPath)
server.configure(player: nil, db: db)
try server.start()
try await Task.sleep(for: .milliseconds(200))
let port = server.actualPort!
// 3. Perform an HTTP GET /db request to download the database
let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db")
// 4. Verify the response starts with the SQLite magic header
let header = String(data: responseData.prefix(16), encoding: .utf8) ?? ""
#expect(header.hasPrefix("SQLite format 3"))
// 5. Write the downloaded data to disk and verify it contains the inserted track
let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path
try responseData.write(to: URL(fileURLWithPath: downloadedPath))
let downloadedDb = try DatabaseService(path: downloadedPath)
#expect(try downloadedDb.trackCount() == 1)
// 6. Clean up
server.stop()
try? FileManager.default.removeItem(at: tempDir)
}
// Connects to /cmd, sends a pause command, verifies a playbackState event comes back.
@Test(.timeLimit(.minutes(1)))
func commandChannelRoundTrip() async throws {
// 1. Create a temp directory and an empty SQLite database
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let dbPath = tempDir.appendingPathComponent("db.sqlite").path
_ = try DatabaseService(path: dbPath)
// 2. Set up the player and server with command dispatch configured
let audio = AudioService()
let player = PlayerViewModel(audio: audio, db: nil)
let server = HostServer(dbPath: dbPath)
server.configure(player: player, db: nil)
try server.start()
try await Task.sleep(for: .milliseconds(200))
let port = server.actualPort!
// 3. Open a command channel connection via GET /cmd
let connection = try await connectCommandChannel(host: "127.0.0.1", port: port)
// 4. Send a pause command as NDJSON
let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause)
var lineData = pauseCmd
lineData.append(contentsOf: "\n".utf8)
connection.send(content: lineData, completion: .contentProcessed { _ in })
// 5. Wait for and decode the playbackState response event
let responseLine = try await receiveOneLine(on: connection)
let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8))
// 6. Verify it is a playbackState event with isPlaying == false
if case .playbackState(let payload) = event {
#expect(payload.isPlaying == false)
} else {
Issue.record("Expected playbackState, got \(event)")
}
// 7. Clean up
connection.cancel()
server.stop()
try? FileManager.default.removeItem(at: tempDir)
}
// MARK: - Helpers
/// Performs a simple HTTP GET using NWConnection and returns the response body.
private func httpGet(host: String, port: UInt16, path: String) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: .tcp
)
connection.stateUpdateHandler = { state in
if case .ready = state {
// Send the HTTP request
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n"
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
// Receive the full response (Connection: close ensures we get everything)
connection.receiveMessage { data, _, _, error in
if let error {
continuation.resume(throwing: error)
} else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) {
// Strip HTTP headers, return just the body
continuation.resume(returning: Data(data[range.upperBound...]))
} else {
continuation.resume(returning: data ?? Data())
}
connection.cancel()
}
} else if case .failed(let error) = state {
continuation.resume(throwing: error)
}
}
connection.start(queue: .main)
}
}
/// Opens a TCP connection to the /cmd endpoint and waits for the HTTP response header.
private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection {
try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: .tcp
)
connection.stateUpdateHandler = { state in
if case .ready = state {
// Send the HTTP upgrade request for the command channel
let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n"
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
// Consume the HTTP 200 response header
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in
continuation.resume(returning: connection)
}
} else if case .failed(let error) = state {
continuation.resume(throwing: error)
}
}
connection.start(queue: .main)
}
}
/// Reads one newline-delimited line from a connection.
private func receiveOneLine(on connection: NWConnection) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
if let error {
continuation.resume(throwing: error)
} else {
let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
// Extract the first complete line from the received data
continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text)
}
}
}
}
}

@ -0,0 +1,32 @@
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\"}"])
}
}

@ -0,0 +1,132 @@
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…
Cancel
Save