chahinebrini d65ba84eb1 feat(binder): MDMClient, EnrollView improvements + supervise flow_backup
- MDMClient: error handling verbessert
- SuperviseRunner: robustere EOF-nach-Success Erkennung
- EnrollView: Enrollment-Status-Polling, Retry-Logik
- SuperviseView: UX-Verbesserungen
- ConfigureView: minor cleanup
- flow_backup.go: backup flow für supervise-magic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 04:30:28 +02:00

378 lines
14 KiB
Swift

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<Void, Never>?
@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)
}
}
}