chahinebrini c1edef8abd feat(magic): RebreakMagic device-binding + DNS profile
- backend: /api/magic/{register,devices,profile,release} + AdGuard provisioning + 24h cooldown
- prisma: magic_binding_fields migration (additive on UserDevice)
- mac-app: Phase 2 - Login + MacRegistration + Profile install
- marketing: landing section + /download/rebreakmagic + DMG
- lyra: forbidden phrases + RebreakMagic coach guidance
2026-06-02 09:15:19 +02:00

315 lines
10 KiB
Swift

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 production
return "https://app.rebreak.org"
}
do {
let data = try Data(contentsOf: url)
let config = try JSONDecoder().decode(Config.self, from: data)
return config.backendBaseUrl ?? "https://app.rebreak.org"
} catch {
return "https://app.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
}
}