Version one on the App Store
This commit is contained in:
@@ -100,17 +100,11 @@ final class MAWebSocketClient {
|
||||
|
||||
/// Connect to Music Assistant server
|
||||
func connect(serverURL: URL, authToken: String?) async throws {
|
||||
print("🔵 MAWebSocketClient.connect: Checking state")
|
||||
guard connectionState == .disconnected else {
|
||||
logger.info("Already connected or connecting")
|
||||
print("⚠️ MAWebSocketClient.connect: Already connected/connecting, state = \(connectionState)")
|
||||
return
|
||||
}
|
||||
|
||||
print("🔵 MAWebSocketClient.connect: Starting connection")
|
||||
print("🔵 MAWebSocketClient.connect: Server URL = \(serverURL.absoluteString)")
|
||||
print("🔵 MAWebSocketClient.connect: Has auth token = \(authToken != nil)")
|
||||
|
||||
self.serverURL = serverURL
|
||||
self.authToken = authToken
|
||||
self.shouldReconnect = true
|
||||
@@ -120,50 +114,77 @@ final class MAWebSocketClient {
|
||||
|
||||
private func performConnect() async throws {
|
||||
guard let serverURL else {
|
||||
print("❌ MAWebSocketClient.performConnect: No server URL")
|
||||
throw ClientError.invalidURL
|
||||
}
|
||||
|
||||
connectionState = .connecting
|
||||
logger.info("Connecting to \(serverURL.absoluteString)")
|
||||
print("🔵 MAWebSocketClient.performConnect: Building WebSocket URL")
|
||||
|
||||
// Build WebSocket URL (ws:// or wss://)
|
||||
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
|
||||
let originalScheme = components.scheme
|
||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||
components.path = "/ws"
|
||||
|
||||
guard let wsURL = components.url else {
|
||||
print("❌ MAWebSocketClient.performConnect: Failed to build WebSocket URL")
|
||||
throw ClientError.invalidURL
|
||||
}
|
||||
|
||||
print("🔵 MAWebSocketClient.performConnect: Original scheme = \(originalScheme ?? "nil")")
|
||||
print("🔵 MAWebSocketClient.performConnect: WebSocket URL = \(wsURL.absoluteString)")
|
||||
logger.debug("WebSocket URL: \(wsURL.absoluteString)")
|
||||
|
||||
var request = URLRequest(url: wsURL)
|
||||
|
||||
// Add auth token if available
|
||||
if let authToken {
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
print("✅ MAWebSocketClient.performConnect: Authorization header added")
|
||||
} else {
|
||||
print("⚠️ MAWebSocketClient.performConnect: No auth token provided")
|
||||
}
|
||||
|
||||
let task = session.webSocketTask(with: request)
|
||||
let task = session.webSocketTask(with: URLRequest(url: wsURL))
|
||||
self.webSocketTask = task
|
||||
|
||||
print("🔵 MAWebSocketClient.performConnect: Starting WebSocket task")
|
||||
task.resume()
|
||||
|
||||
// Start listening for messages
|
||||
startReceiving()
|
||||
do {
|
||||
// MA sends a server-info message immediately on connect; receive and discard it
|
||||
_ = try await task.receive()
|
||||
logger.debug("Received server info")
|
||||
|
||||
// Send auth command and wait for confirmation
|
||||
if let authToken {
|
||||
try await performAuth(task: task, token: authToken)
|
||||
logger.info("Authenticated successfully")
|
||||
}
|
||||
|
||||
// Now safe to start the regular message loop
|
||||
startReceiving()
|
||||
connectionState = .connected
|
||||
logger.info("Connected successfully")
|
||||
|
||||
} catch {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
connectionState = .disconnected
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func performAuth(task: URLSessionWebSocketTask, token: String) async throws {
|
||||
let messageId = UUID().uuidString
|
||||
let cmd = MACommand(
|
||||
messageId: messageId,
|
||||
command: "auth",
|
||||
args: ["token": AnyCodable(token)]
|
||||
)
|
||||
let data = try JSONEncoder().encode(cmd)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw ClientError.decodingError(NSError(domain: "Encoding", code: -1))
|
||||
}
|
||||
|
||||
connectionState = .connected
|
||||
logger.info("Connected successfully")
|
||||
print("✅ MAWebSocketClient.performConnect: Connection successful")
|
||||
try await task.send(.string(json))
|
||||
|
||||
// Receive the auth response
|
||||
let result = try await task.receive()
|
||||
guard case .string(let responseText) = result,
|
||||
let responseData = responseText.data(using: .utf8) else {
|
||||
throw ClientError.serverError("Invalid auth response format")
|
||||
}
|
||||
|
||||
if let response = try? JSONDecoder().decode(MAResponse.self, from: responseData),
|
||||
let errorCode = response.errorCode {
|
||||
throw ClientError.serverError(response.details ?? "Authentication failed (code \(errorCode))")
|
||||
}
|
||||
// Non-error response means auth succeeded
|
||||
}
|
||||
|
||||
/// Disconnect from server
|
||||
@@ -172,19 +193,21 @@ final class MAWebSocketClient {
|
||||
shouldReconnect = false
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
|
||||
// Nil out BEFORE cancel so any in-flight receive callbacks see nil and exit early
|
||||
let task = webSocketTask
|
||||
webSocketTask = nil
|
||||
|
||||
connectionState = .disconnected
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
|
||||
// Cancel all pending requests
|
||||
requestQueue.sync {
|
||||
for (messageId, continuation) in pendingRequests {
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: ClientError.notConnected)
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
}
|
||||
|
||||
connectionState = .disconnected
|
||||
|
||||
eventContinuation?.finish()
|
||||
}
|
||||
|
||||
@@ -192,17 +215,22 @@ final class MAWebSocketClient {
|
||||
|
||||
private func startReceiving() {
|
||||
guard let task = webSocketTask else { return }
|
||||
|
||||
|
||||
task.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self.handleMessage(message)
|
||||
// Continue listening
|
||||
self.startReceiving()
|
||||
|
||||
// Only continue if we are still connected to this same task
|
||||
if self.webSocketTask === task {
|
||||
self.startReceiving()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
// URLError.cancelled is expected during a clean disconnect — not a real error
|
||||
let nsError = error as NSError
|
||||
guard nsError.code != URLError.cancelled.rawValue else { return }
|
||||
logger.error("WebSocket receive error: \(error.localizedDescription)")
|
||||
self.handleDisconnection()
|
||||
}
|
||||
@@ -245,7 +273,7 @@ final class MAWebSocketClient {
|
||||
|
||||
// Check for error
|
||||
if let errorCode = response.errorCode {
|
||||
let errorMsg = response.errorMessage ?? errorCode
|
||||
let errorMsg = response.details ?? "Error code: \(errorCode)"
|
||||
continuation.resume(throwing: ClientError.serverError(errorMsg))
|
||||
} else {
|
||||
continuation.resume(returning: response)
|
||||
@@ -259,9 +287,14 @@ final class MAWebSocketClient {
|
||||
}
|
||||
|
||||
private func handleDisconnection() {
|
||||
// Idempotency guard — can be called from receive callback and disconnect() simultaneously
|
||||
guard connectionState != .disconnected else { return }
|
||||
|
||||
connectionState = .disconnected
|
||||
let task = webSocketTask
|
||||
webSocketTask = nil
|
||||
|
||||
task?.cancel(with: .goingAway, reason: nil)
|
||||
|
||||
// Cancel pending requests
|
||||
requestQueue.sync {
|
||||
for (_, continuation) in pendingRequests {
|
||||
@@ -269,7 +302,7 @@ final class MAWebSocketClient {
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
}
|
||||
|
||||
|
||||
// Attempt reconnection if needed
|
||||
if shouldReconnect {
|
||||
scheduleReconnect(attempt: 1)
|
||||
@@ -372,8 +405,32 @@ final class MAWebSocketClient {
|
||||
}
|
||||
|
||||
do {
|
||||
// Debug: Log the raw result before decoding
|
||||
if let jsonData = try? JSONEncoder().encode(result),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
logger.debug("📦 Response result for '\(command)': \(jsonString)")
|
||||
}
|
||||
|
||||
return try result.decode(as: T.self)
|
||||
} catch {
|
||||
logger.error("❌ Failed to decode result for '\(command)': \(error.localizedDescription)")
|
||||
|
||||
// Log more details about the decoding error
|
||||
if let decodingError = error as? DecodingError {
|
||||
switch decodingError {
|
||||
case .dataCorrupted(let context):
|
||||
logger.error("Data corrupted: \(context.debugDescription)")
|
||||
case .keyNotFound(let key, let context):
|
||||
logger.error("Key '\(key.stringValue)' not found: \(context.debugDescription)")
|
||||
case .typeMismatch(let type, let context):
|
||||
logger.error("Type mismatch for \(type): \(context.debugDescription)")
|
||||
case .valueNotFound(let type, let context):
|
||||
logger.error("Value not found for \(type): \(context.debugDescription)")
|
||||
@unknown default:
|
||||
logger.error("Unknown decoding error")
|
||||
}
|
||||
}
|
||||
|
||||
throw ClientError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user