From d65ba84eb14b921a5293326601fe15adcc04fb9d Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 04:30:28 +0200 Subject: [PATCH] feat(binder): MDMClient, EnrollView improvements + supervise flow_backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Sources/Services/MDMClient.swift | 11 ++++ .../Sources/Services/SuperviseRunner.swift | 10 ++-- .../Sources/Views/ConfigureView.swift | 11 ++-- .../Sources/Views/EnrollView.swift | 53 ++++++++++++++++--- .../Sources/Views/SuperviseView.swift | 13 ++++- .../internal/supervise/flow_backup.go | 8 +++ 6 files changed, 91 insertions(+), 15 deletions(-) diff --git a/apps/rebreak-magic-mac/Sources/Services/MDMClient.swift b/apps/rebreak-magic-mac/Sources/Services/MDMClient.swift index a9d9216..1d3764a 100644 --- a/apps/rebreak-magic-mac/Sources/Services/MDMClient.swift +++ b/apps/rebreak-magic-mac/Sources/Services/MDMClient.swift @@ -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. diff --git a/apps/rebreak-magic-mac/Sources/Services/SuperviseRunner.swift b/apps/rebreak-magic-mac/Sources/Services/SuperviseRunner.swift index 1cf8299..b186308 100644 --- a/apps/rebreak-magic-mac/Sources/Services/SuperviseRunner.swift +++ b/apps/rebreak-magic-mac/Sources/Services/SuperviseRunner.swift @@ -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: [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) } } diff --git a/apps/rebreak-magic-mac/Sources/Views/ConfigureView.swift b/apps/rebreak-magic-mac/Sources/Views/ConfigureView.swift index b76588c..beb75ec 100644 --- a/apps/rebreak-magic-mac/Sources/Views/ConfigureView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/ConfigureView.swift @@ -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 { diff --git a/apps/rebreak-magic-mac/Sources/Views/EnrollView.swift b/apps/rebreak-magic-mac/Sources/Views/EnrollView.swift index 13c110c..cd79452 100644 --- a/apps/rebreak-magic-mac/Sources/Views/EnrollView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/EnrollView.swift @@ -79,6 +79,7 @@ struct EnrollView: View { @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" @@ -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." } } } diff --git a/apps/rebreak-magic-mac/Sources/Views/SuperviseView.swift b/apps/rebreak-magic-mac/Sources/Views/SuperviseView.swift index 8447623..347cd77 100644 --- a/apps/rebreak-magic-mac/Sources/Views/SuperviseView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/SuperviseView.swift @@ -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) diff --git a/ops/mdm/supervise-magic/internal/supervise/flow_backup.go b/ops/mdm/supervise-magic/internal/supervise/flow_backup.go index b52ffd0..892476f 100644 --- a/ops/mdm/supervise-magic/internal/supervise/flow_backup.go +++ b/ops/mdm/supervise-magic/internal/supervise/flow_backup.go @@ -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)