import Foundation /// Response-Modelle für /api/magic/* Endpoints struct MagicRegistration: Codable { let deviceId: String let dnsToken: String let profileUrl: String let existing: Bool } enum MagicDeviceSource: String, Codable { case magic case locked case protected } struct MagicDevice: Codable, Identifiable { let source: MagicDeviceSource? let deviceId: String let hostname: String let model: String? let osVersion: String? let magicEnrolledAt: String let releaseRequestedAt: String? let releaseAvailableAt: String? var id: String { "\(source?.rawValue ?? "magic"):\(deviceId)" } /// Default zu `.magic` falls Backend (alte Version) das Feld nicht setzt. var resolvedSource: MagicDeviceSource { source ?? .magic } var enrolledDate: Date? { parseISO(magicEnrolledAt) } var releaseDate: Date? { guard let iso = releaseAvailableAt else { return nil } return parseISO(iso) } var isReleasing: Bool { releaseRequestedAt != nil } } struct MagicReleaseResponse: Codable { let releaseRequestedAt: String let releaseAvailableAt: String var releaseDate: Date? { parseISO(releaseAvailableAt) } } /// Parses ISO8601 mit + ohne fractional seconds (Backend sendet `.000Z`-Suffix). private func parseISO(_ s: String) -> Date? { let f1 = ISO8601DateFormatter() f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let d = f1.date(from: s) { return d } let f2 = ISO8601DateFormatter() f2.formatOptions = [.withInternetDateTime] return f2.date(from: s) } /// User-Profil aus /api/magic/me \u2014 f\u00fcr Hub-Header (Avatar + Nickname). struct MagicUserProfile: Codable { let nickname: String? let avatar: String? let plan: String? } 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: - User Profile (Hub-Header) func fetchMe() async throws -> MagicUserProfile { let session = try await authService.refreshSessionIfNeeded() let url = try URL(string: "\(baseURL)/api/magic/me")! 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: MagicUserProfile } do { return try JSONDecoder().decode(Response.self, from: data).data } catch { throw MagicError.decodingError(error.localizedDescription) } } // 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 } }