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>
This commit is contained in:
chahinebrini 2026-06-01 04:30:28 +02:00
parent db0aa6d24e
commit d65ba84eb1
6 changed files with 91 additions and 15 deletions

View File

@ -142,6 +142,17 @@ enum MDMClient {
return try await enqueue(udid: udid, command: cmd).responseBody
}
/// Entfernt eine App per MDM-Command (RemoveApplication). Funktioniert
/// auf supervised-iPhones ohne User-Confirm. Returnt die EnqueueResult-
/// commandUUID, damit Caller per readCommandResult auf Ack warten kann.
static func removeApp(udid: String, bundleID: String = "org.rebreak.app") async throws -> EnqueueResult {
let cmd: [String: Any] = [
"RequestType": "RemoveApplication",
"Identifier": bundleID,
]
return try await enqueue(udid: udid, command: cmd)
}
/// Pusht ManagedApplicationList-Query an iPhone (welche Apps managed?).
/// Returnt die generierte CommandUUID Caller liest danach via
/// `MDMStatus.readCommandResult(udid:, commandUUID:)` das Ergebnis.

View File

@ -25,7 +25,7 @@ enum SuperviseRunner {
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
throw RunnerError.binaryMissing
}
return try await ProcessRunner.stream(bin, arguments: ["-v", "check"], onLine: onLine)
return try await ProcessRunner.stream(bin, arguments: ["check", "-v"], onLine: onLine)
}
/// Schreibt CloudConfigurationDetails.plist auf das iPhone + reboot.
@ -41,9 +41,11 @@ enum SuperviseRunner {
throw RunnerError.binaryMissing
}
// -yes ist Pflicht: ohne TTY-Pipe hängt der Bestätigungs-Prompt sonst endlos.
var args: [String] = verbose ? ["-v", "-yes"] : ["-yes"]
// CLI-Konvention: <command> [flags] command MUSS zuerst kommen.
var args: [String] = ["supervise", "-yes"]
if verbose { args.append("-v") }
if force { args.append("-force") }
args.append(contentsOf: ["-org", organizationName, "supervise"])
args.append(contentsOf: ["-org", organizationName])
let result = try await ProcessRunner.stream(bin, arguments: args, onLine: onLine)
if result.exitCode != 0 {
throw RunnerError.nonZeroExit(result.exitCode)
@ -57,6 +59,6 @@ enum SuperviseRunner {
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
throw RunnerError.binaryMissing
}
return try await ProcessRunner.stream(bin, arguments: ["-v", "-yes", "unsupervise"], onLine: onLine)
return try await ProcessRunner.stream(bin, arguments: ["unsupervise", "-v", "-yes"], onLine: onLine)
}
}

View File

@ -255,15 +255,18 @@ struct ConfigureView: View {
let pushStartTime = Date()
// Harte Variante fuer robuste Tests:
// Wenn ReBreak-App schon da ist, zuerst löschen und dann frisch pushen.
// Wenn ReBreak-App schon da ist, zuerst per MDM-Command
// (RemoveApplication) entfernen KEIN cfgutil mehr, weil
// cfgutil remove-app auf iOS 18+ den 804-Fehler wirft.
let appAlreadyInstalled = await DeviceDetector.installedAppBundleIDs().contains("org.rebreak.app")
if appAlreadyInstalled {
model.configureLog.append("→ Hard-Reinstall: vorhandene ReBreak-App wird entfernt …")
try await DeviceDetector.removeApp(bundleID: "org.rebreak.app")
model.configureLog.append("→ Hard-Reinstall: vorhandene ReBreak-App wird via MDM entfernt …")
let removeResult = try await MDMClient.removeApp(udid: udid)
model.configureLog.append("✓ enqueued RemoveApplication: \(removeResult.commandUUID.prefix(8))")
let removed = await waitForAppInstalled(expectedInstalled: false)
if !removed {
throw NSError(domain: "Binder", code: 7, userInfo: [NSLocalizedDescriptionKey:
"Vorhandene ReBreak-App konnte nicht sicher entfernt werden."])
"MDM-RemoveApplication wurde gepusht aber iPhone hat App nicht entfernt. Bitte Step 4 (Enroll) wiederholen."])
}
model.configureLog.append("✓ Vorhandene ReBreak-App entfernt.")
} else {

View File

@ -79,6 +79,7 @@ struct EnrollView: View {
@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"
@ -176,8 +177,24 @@ struct EnrollView: View {
HStack {
Button("Zurück") { model.goTo(.supervise) }
.buttonStyle(.bordered)
.disabled(busy)
Spacer()
if enrollmentReady {
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)
@ -190,13 +207,14 @@ struct EnrollView: View {
}
private func startIfNeeded() {
if localPath == nil && !busy && !enrollmentReady {
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 {
@ -204,14 +222,36 @@ struct EnrollView: View {
guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
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)
}
try data.write(to: URL(fileURLWithPath: dest))
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) (\(data.count) Bytes)"
downloadStatus = "Geladen: \(dest) (\(profileData.count) Bytes)"
}
await MainActor.run {
runInstallFlow(path: dest)
@ -220,6 +260,7 @@ struct EnrollView: View {
await MainActor.run {
busy = false
downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)"
enrollError = "MDM-Server nicht erreichbar (\(error.localizedDescription)). Bitte später erneut versuchen."
}
}
}

View File

@ -113,6 +113,14 @@ struct SuperviseView: View {
}
private func startIfNeeded() {
// Skip wenn iPad bereits von ReBreak supervised direkt zum nächsten Step.
if model.device?.isSupervised == true,
(model.device?.supervisorOrgName ?? "") == "ReBreak" {
model.supervisionLog = ["✓ Bereits von ReBreak supervised — überspringe."]
model.supervisionError = nil
model.supervisionRunning = false
return
}
if model.supervisionLog.isEmpty && !model.supervisionRunning && model.supervisionError == nil {
startSupervise()
}
@ -125,9 +133,12 @@ struct SuperviseView: View {
task?.cancel()
task = Task { @MainActor in
do {
// force=false: wenn Device schon supervised ist (z.B. nach
// partial-success + Retry), exitet CLI mit 0 + Hinweis statt
// den ganzen Backup-Restore-Flow nochmal durchzulaufen.
_ = try await SuperviseRunner.supervise(
organizationName: "ReBreak",
force: true,
force: false,
verbose: model.showAdvancedLogs
) { line in
model.supervisionLog.append(line)

View File

@ -325,6 +325,14 @@ func commitViaMCInstall(conn *device.Conn, certDER []byte, orgName string, logf
})
mc.Close()
if err != nil {
// 14002 "A cloud configuration is already present" = Backup-Sandwich
// hat CloudConfig bereits geschrieben → MCInstall-Re-Apply redundant,
// Supervise war erfolgreich.
errStr := err.Error()
if strings.Contains(errStr, "14002") || strings.Contains(errStr, "already present") {
logf(" ✓ MCInstall reports CloudConfig already present (attempt %d) — supervision committed via backup-sandwich", attempt)
return nil
}
lastErr = err
logf(" · attempt %d/%d: Supervise failed: %v", attempt, maxAttempts, err)
time.Sleep(retryDelay)