import Foundation /// Response-Modelle für /api/magic/* Endpoints struct MagicRegistration: Codable { let deviceId: String let dnsToken: String let profileUrl: String let existing: Bool } struct MagicDevice: Codable, Identifiable { let deviceId: String let hostname: String let model: String? let osVersion: String? let magicEnrolledAt: String let releaseRequestedAt: String? let releaseAvailableAt: String? var id: String { deviceId } var enrolledDate: Date? { ISO8601DateFormatter().date(from: magicEnrolledAt) } var releaseDate: Date? { guard let iso = releaseAvailableAt else { return nil } return ISO8601DateFormatter().date(from: iso) } var isReleasing: Bool { releaseRequestedAt != nil } } struct MagicReleaseResponse: Codable { let releaseRequestedAt: String let releaseAvailableAt: String var releaseDate: Date? { ISO8601DateFormatter().date(from: releaseAvailableAt) } } enum MagicError: Error, LocalizedError { case unauthorized case limitReached(activeBindings: [MagicDevice]) case networkError(String) case httpError(Int, String) case configMissing(String) case decodingError(String) var errorDescription: String? { switch self { case .unauthorized: return "Nicht authentifiziert. Bitte neu einloggen." case .limitReached(let bindings): return "Device-Limit erreicht (\(bindings.count) Geräte). Bitte zuerst ein Gerät freigeben." case .networkError(let msg): return "Netzwerkfehler: \(msg)" case .httpError(let status, let msg): return "HTTP \(status): \(msg)" case .configMissing(let msg): return "Config fehlt: \(msg)" case .decodingError(let msg): return "Response-Parse-Fehler: \(msg)" } } } /// HTTP-Client für ReBreak Magic Backend API (/api/magic/*). /// Injiziert automatisch JWT-Auth via AuthService. @MainActor final class MagicAPIClient { static let shared = MagicAPIClient() private let authService = AuthService.shared private init() {} // MARK: - Config private struct Config: Codable { let backendBaseUrl: String? } private static let configPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.config/rebreak-magic/config.json" }() private var baseURL: String { get throws { // Override via env var for testing if let envUrl = ProcessInfo.processInfo.environment["REBREAK_BACKEND_URL"] { return envUrl } let url = URL(fileURLWithPath: Self.configPath) guard FileManager.default.fileExists(atPath: Self.configPath) else { // Default to staging (app.rebreak.org hat aktuell falsches TLS-Zert) return "https://staging.rebreak.org" } do { let data = try Data(contentsOf: url) let config = try JSONDecoder().decode(Config.self, from: data) return config.backendBaseUrl ?? "https://staging.rebreak.org" } catch { return "https://staging.rebreak.org" } } } // MARK: - Register Device func register(deviceId: String, hostname: String, model: String, osVersion: String) async throws -> MagicRegistration { let session = try await authService.refreshSessionIfNeeded() let url = try URL(string: "\(baseURL)/api/magic/register")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization") let body: [String: String] = [ "deviceId": deviceId, "hostname": hostname, "model": model, "osVersion": osVersion ] request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MagicError.networkError("Keine HTTP-Response") } if httpResponse.statusCode == 401 { await authService.signOut() throw MagicError.unauthorized } if httpResponse.statusCode == 409 { // Limit reached struct LimitError: Codable { let code: String let activeBindings: [MagicDevice] } struct ErrorResponse: Codable { let data: LimitError } if let errorData = try? JSONDecoder().decode(ErrorResponse.self, from: data) { throw MagicError.limitReached(activeBindings: errorData.data.activeBindings) } throw MagicError.httpError(409, "Device-Limit erreicht") } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw MagicError.httpError(httpResponse.statusCode, body) } struct Response: Codable { let success: Bool let data: MagicRegistration } do { let response = try JSONDecoder().decode(Response.self, from: data) return response.data } catch { throw MagicError.decodingError(error.localizedDescription) } } // MARK: - List Devices func listDevices() async throws -> [MagicDevice] { let session = try await authService.refreshSessionIfNeeded() let url = try URL(string: "\(baseURL)/api/magic/devices")! var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MagicError.networkError("Keine HTTP-Response") } if httpResponse.statusCode == 401 { await authService.signOut() throw MagicError.unauthorized } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw MagicError.httpError(httpResponse.statusCode, body) } struct Response: Codable { let success: Bool let data: [MagicDevice] } do { let response = try JSONDecoder().decode(Response.self, from: data) return response.data } catch { throw MagicError.decodingError(error.localizedDescription) } } // MARK: - Request Release func requestRelease(deviceId: String) async throws -> Date { let session = try await authService.refreshSessionIfNeeded() let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/request-release")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MagicError.networkError("Keine HTTP-Response") } if httpResponse.statusCode == 401 { await authService.signOut() throw MagicError.unauthorized } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw MagicError.httpError(httpResponse.statusCode, body) } struct Response: Codable { let success: Bool let data: MagicReleaseResponse } do { let response = try JSONDecoder().decode(Response.self, from: data) guard let date = response.data.releaseDate else { throw MagicError.decodingError("releaseAvailableAt parse failed") } return date } catch { throw MagicError.decodingError(error.localizedDescription) } } // MARK: - Cancel Release func cancelRelease(deviceId: String) async throws { let session = try await authService.refreshSessionIfNeeded() let url = try URL(string: "\(baseURL)/api/magic/devices/\(deviceId)/cancel-release")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MagicError.networkError("Keine HTTP-Response") } if httpResponse.statusCode == 401 { await authService.signOut() throw MagicError.unauthorized } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw MagicError.httpError(httpResponse.statusCode, body) } } // MARK: - Download Profile func downloadProfile(token: String) async throws -> URL { let url = try URL(string: "\(baseURL)/api/magic/profile.mobileconfig?token=\(token)")! var request = URLRequest(url: url) request.httpMethod = "GET" // KEIN JWT — Token in Query let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MagicError.networkError("Keine HTTP-Response") } guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8) ?? "" throw MagicError.httpError(httpResponse.statusCode, body) } // Save to tmp let tmpDir = FileManager.default.temporaryDirectory let profilePath = tmpDir.appendingPathComponent("RebreakMagic-\(UUID().uuidString).mobileconfig") try data.write(to: profilePath) return profilePath } }