- 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>
378 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|