Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
@@ -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)
}
}