- backend/api/magic/register: explicit import of MAGIC_DEVICE_LIMIT and createAdGuardClient (Nitro auto-import was missing them → ReferenceError → HTTP 500 on /api/magic/register) - mac-app: default backendBaseUrl falls back to staging.rebreak.org (app.rebreak.org serves wrong TLS cert) - native MagicSheet: fallback download/dmg URLs point to staging - native settings: Magic sheet capped at detents=[0.85] so AppHeader stays visible - bundles all in-flight Magic feature work (pair create/redeem, device endpoints, schema, adguard utils, mac-app, locales)
315 lines
10 KiB
Swift
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 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
|
|
}
|
|
}
|