import SwiftUI import AppKit struct TransferAnimationView: View { let leftSymbol: String let rightSymbol: String let title: String let subtitle: String let isActive: Bool let isDone: Bool @State private var animate = false var body: some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.callout.weight(.semibold)) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 14) { iconNode(systemName: leftSymbol) ZStack(alignment: .leading) { Capsule() .fill(Color.gray.opacity(0.22)) .frame(height: 6) if isDone { Capsule() .fill(Color.green) .frame(height: 6) } else if isActive { Circle() .fill(Color.accentColor) .frame(width: 12, height: 12) .offset(x: animate ? 150 : 0) .animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: false), value: animate) } } .frame(width: 150) iconNode(systemName: rightSymbol) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.accentColor.opacity(0.06)) .cornerRadius(10) .onAppear { animate = isActive } .onChange(of: isActive) { _, active in animate = active } } private func iconNode(systemName: String) -> some View { ZStack { Circle() .fill(Color.accentColor.opacity(0.12)) .frame(width: 34, height: 34) Image(systemName: systemName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.tint) } } } struct EnrollView: View { @Environment(WizardModel.self) private var model @State private var downloadStatus: String? @State private var localPath: String? @State private var flowStatus: String? @State private var busy = false @State private var enrollmentReady = false @State private var pollTask: Task? @State private var didAutoAdvance = false @State private var showUnlockModal = false @State private var enrollError: String? private let enrollmentProfileID = "org.rebreak.mdm.enrollment" var body: some View { VStack(alignment: .leading, spacing: 20) { header Text("Wir installieren jetzt automatisch das Verbindungs-Profil für die Geräteverwaltung. Danach prüfen wir selbst, ob alles korrekt aktiv ist.") .foregroundStyle(.secondary) TransferAnimationView( leftSymbol: "iphone.gen3", rightSymbol: "server.rack", title: "Enrollment Live-Status", subtitle: enrollmentReady ? "Profil aktiv und iPhone am ReBreak-Server bestätigt enrolled." : "Warte auf Profil-Installation am iPhone und Backend-Enrollment.", isActive: busy && !enrollmentReady, isDone: enrollmentReady ) instructions Spacer() navigationBar } .padding(40) .onAppear { startIfNeeded() } .onDisappear { pollTask?.cancel() } .alert("iPhone entsperren", isPresented: $showUnlockModal) { Button("Erneut versuchen") { if let path = localPath { busy = true runInstallFlow(path: path) } } Button("OK", role: .cancel) {} } message: { Text("Bitte iPhone entsperren und verbunden lassen. Danach erneut versuchen.") } } private var header: some View { HStack { Image(systemName: "doc.badge.gearshape") .font(.system(size: 30)) .foregroundStyle(.tint) Text("Verbindung einrichten") .font(.title).bold() } } private var instructions: some View { VStack(alignment: .leading, spacing: 14) { stepRow(number: 1, text: "Profil wird automatisch geladen.") if let status = downloadStatus { HStack(spacing: 8) { Image(systemName: localPath != nil ? "checkmark.circle.fill" : "arrow.down.circle") .foregroundStyle(localPath != nil ? .green : .secondary) Text(status).font(.caption).foregroundStyle(.secondary) } .padding(.leading, 36) } stepRow(number: 2, text: "Automatische Installation wird versucht.") if let status = flowStatus { HStack(spacing: 8) { Image(systemName: enrollmentReady ? "checkmark.circle.fill" : (busy ? "hourglass" : "info.circle")) .foregroundStyle(enrollmentReady ? .green : .secondary) Text(status).font(.caption).foregroundStyle(.secondary) } .padding(.leading, 36) } stepRow(number: 3, text: "Wenn iOS den Profil-Dialog zeigt, bitte direkt am iPhone bestätigen.") stepRow(number: 4, text: "Wir warten automatisch auf Profil aktiv + Backend-Enroll und schalten dann Weiter frei.") } } private func stepRow(number: Int, text: String) -> some View { HStack(alignment: .top, spacing: 12) { ZStack { Circle().fill(Color.accentColor) Text("\(number)").foregroundStyle(.white).bold() } .frame(width: 24, height: 24) Text(text) .fixedSize(horizontal: false, vertical: true) Spacer() } } private var navigationBar: some View { HStack { Button("Zurück") { model.goTo(.supervise) } .buttonStyle(.bordered) .disabled(busy) Spacer() if let err = enrollError { HStack(spacing: 8) { Text(err) .font(.callout) .foregroundStyle(.red) .lineLimit(2) Button("Erneut versuchen") { enrollError = nil downloadStatus = nil flowStatus = nil localPath = nil downloadProfile() } .buttonStyle(.bordered) } } else if enrollmentReady { Text("Enrollment bestätigt. Weiterleitung läuft automatisch …") .font(.callout) .foregroundStyle(.secondary) } else { Text("Bitte kurz warten …") .font(.callout) .foregroundStyle(.secondary) } } } private func startIfNeeded() { if localPath == nil && !busy && !enrollmentReady && enrollError == nil { downloadProfile() } } private func downloadProfile() { let dest = "/tmp/rebreak-enrollment.mobileconfig" let udid = model.device?.udid busy = true downloadStatus = "Lade von mdm.rebreak.org …" Task { do { guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else { throw URLError(.badURL) } var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") request.setValue("no-cache", forHTTPHeaderField: "Pragma") let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } guard http.statusCode == 200 else { let body = String(data: data, encoding: .utf8)?.prefix(200) ?? "" throw NSError(domain: "RebreakMDM", code: http.statusCode, userInfo: [ NSLocalizedDescriptionKey: "MDM-Server hat Status \(http.statusCode) zurückgegeben. \(body)" ]) } // Subject-Substitution: iOS ersetzt %SerialNumber%/%UDID% nur bei // DEP-installed Profilen, NICHT bei Safari/AirDrop. Wir patchen // die Variablen lokal mit der echten UDID, damit jeder Device-Bind // einen eindeutigen DN beim SCEP-Server erzeugt. var profileData = data if let udid, var text = String(data: data, encoding: .utf8) { text = text.replacingOccurrences(of: "%SerialNumber%", with: udid) text = text.replacingOccurrences(of: "%UDID%", with: udid) if let patched = text.data(using: .utf8) { profileData = patched } } try profileData.write(to: URL(fileURLWithPath: dest)) await MainActor.run { localPath = dest downloadStatus = "Geladen: \(dest) (\(profileData.count) Bytes)" } await MainActor.run { runInstallFlow(path: dest) } } catch { await MainActor.run { busy = false downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)" enrollError = "MDM-Server nicht erreichbar (\(error.localizedDescription)). Bitte später erneut versuchen." } } } } private func runInstallFlow(path: String) { guard !enrollmentReady else { return } flowStatus = "Versuche automatische Installation via USB …" Task { var installSucceeded = false var shouldPollEnrollment = false for attempt in 1...3 { do { try await DeviceDetector.installProfileSilently(path: path) installSucceeded = true shouldPollEnrollment = true await MainActor.run { flowStatus = "✓ Profil wurde übertragen. Prüfe Installation + Enrollment …" } break } catch DeviceDetector.DetectorError.deviceLocked { if attempt < 3 { await MainActor.run { flowStatus = "iPhone ist gesperrt. Retry \(attempt)/2 … bitte entsperren." } try? await Task.sleep(for: .seconds(3)) continue } await MainActor.run { busy = false flowStatus = "iPhone weiter gesperrt. Bitte entsperren und erneut versuchen." showUnlockModal = true } return } catch DeviceDetector.DetectorError.profileUserInteractionRequired { shouldPollEnrollment = true await MainActor.run { flowStatus = "Bitte am iPhone Profil bestätigen. Wir prüfen danach automatisch weiter …" } break } catch { await MainActor.run { flowStatus = "iOS verlangt Bestätigung am Gerät: \(error.localizedDescription)" } break } } if installSucceeded || shouldPollEnrollment { await waitForEnrollmentReady() } else { await MainActor.run { busy = false } } } } private func waitForEnrollmentReady() async { pollTask?.cancel() let task = Task { for _ in 0..<40 { let profiles = await DeviceDetector.installedProfileIDs() let hasProfile = profiles.contains(enrollmentProfileID) let isBackendEnrolled = await checkBackendEnrolled() if hasProfile, isBackendEnrolled { await MainActor.run { busy = false enrollmentReady = true model.device?.isEnrolled = true flowStatus = "✓ Profil aktiv und Server-Enrollment bestätigt." triggerAutomaticContinue() } return } if hasProfile { await MainActor.run { flowStatus = "Profil aktiv. Warte auf Enrollment-Check-In am Server …" } } try? await Task.sleep(for: .seconds(3)) } await MainActor.run { busy = false flowStatus = "⚠ Enrollment noch nicht vollständig bestätigt. Bitte iPhone-Profil-Dialog prüfen." } } pollTask = task _ = await task.result } private func checkBackendEnrolled() async -> Bool { guard let udid = model.device?.udid else { return false } guard let status = try? await MDMStatus.query(udid: udid) else { return false } await MainActor.run { model.device?.enrollmentStatus = status if status.isEnrolled { model.device?.isEnrolled = true } } return status.isEnrolled } @MainActor private func triggerAutomaticContinue() { guard enrollmentReady, !didAutoAdvance else { return } didAutoAdvance = true Task { @MainActor in try? await Task.sleep(for: .seconds(0.8)) model.goTo(.configure) } } }