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:
parent
db0aa6d24e
commit
d65ba84eb1
@ -142,6 +142,17 @@ enum MDMClient {
|
|||||||
return try await enqueue(udid: udid, command: cmd).responseBody
|
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?).
|
/// Pusht ManagedApplicationList-Query an iPhone (welche Apps managed?).
|
||||||
/// Returnt die generierte CommandUUID — Caller liest danach via
|
/// Returnt die generierte CommandUUID — Caller liest danach via
|
||||||
/// `MDMStatus.readCommandResult(udid:, commandUUID:)` das Ergebnis.
|
/// `MDMStatus.readCommandResult(udid:, commandUUID:)` das Ergebnis.
|
||||||
|
|||||||
@ -25,7 +25,7 @@ enum SuperviseRunner {
|
|||||||
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
|
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
|
||||||
throw RunnerError.binaryMissing
|
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.
|
/// Schreibt CloudConfigurationDetails.plist auf das iPhone + reboot.
|
||||||
@ -41,9 +41,11 @@ enum SuperviseRunner {
|
|||||||
throw RunnerError.binaryMissing
|
throw RunnerError.binaryMissing
|
||||||
}
|
}
|
||||||
// -yes ist Pflicht: ohne TTY-Pipe hängt der Bestätigungs-Prompt sonst endlos.
|
// -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") }
|
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)
|
let result = try await ProcessRunner.stream(bin, arguments: args, onLine: onLine)
|
||||||
if result.exitCode != 0 {
|
if result.exitCode != 0 {
|
||||||
throw RunnerError.nonZeroExit(result.exitCode)
|
throw RunnerError.nonZeroExit(result.exitCode)
|
||||||
@ -57,6 +59,6 @@ enum SuperviseRunner {
|
|||||||
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
|
guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
|
||||||
throw RunnerError.binaryMissing
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -255,15 +255,18 @@ struct ConfigureView: View {
|
|||||||
let pushStartTime = Date()
|
let pushStartTime = Date()
|
||||||
|
|
||||||
// Harte Variante fuer robuste Tests:
|
// 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")
|
let appAlreadyInstalled = await DeviceDetector.installedAppBundleIDs().contains("org.rebreak.app")
|
||||||
if appAlreadyInstalled {
|
if appAlreadyInstalled {
|
||||||
model.configureLog.append("→ Hard-Reinstall: vorhandene ReBreak-App wird entfernt …")
|
model.configureLog.append("→ Hard-Reinstall: vorhandene ReBreak-App wird via MDM entfernt …")
|
||||||
try await DeviceDetector.removeApp(bundleID: "org.rebreak.app")
|
let removeResult = try await MDMClient.removeApp(udid: udid)
|
||||||
|
model.configureLog.append("✓ enqueued RemoveApplication: \(removeResult.commandUUID.prefix(8))")
|
||||||
let removed = await waitForAppInstalled(expectedInstalled: false)
|
let removed = await waitForAppInstalled(expectedInstalled: false)
|
||||||
if !removed {
|
if !removed {
|
||||||
throw NSError(domain: "Binder", code: 7, userInfo: [NSLocalizedDescriptionKey:
|
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.")
|
model.configureLog.append("✓ Vorhandene ReBreak-App entfernt.")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -79,6 +79,7 @@ struct EnrollView: View {
|
|||||||
@State private var pollTask: Task<Void, Never>?
|
@State private var pollTask: Task<Void, Never>?
|
||||||
@State private var didAutoAdvance = false
|
@State private var didAutoAdvance = false
|
||||||
@State private var showUnlockModal = false
|
@State private var showUnlockModal = false
|
||||||
|
@State private var enrollError: String?
|
||||||
|
|
||||||
private let enrollmentProfileID = "org.rebreak.mdm.enrollment"
|
private let enrollmentProfileID = "org.rebreak.mdm.enrollment"
|
||||||
|
|
||||||
@ -176,8 +177,24 @@ struct EnrollView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Button("Zurück") { model.goTo(.supervise) }
|
Button("Zurück") { model.goTo(.supervise) }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(busy)
|
||||||
Spacer()
|
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 …")
|
Text("Enrollment bestätigt. Weiterleitung läuft automatisch …")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -190,13 +207,14 @@ struct EnrollView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startIfNeeded() {
|
private func startIfNeeded() {
|
||||||
if localPath == nil && !busy && !enrollmentReady {
|
if localPath == nil && !busy && !enrollmentReady && enrollError == nil {
|
||||||
downloadProfile()
|
downloadProfile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadProfile() {
|
private func downloadProfile() {
|
||||||
let dest = "/tmp/rebreak-enrollment.mobileconfig"
|
let dest = "/tmp/rebreak-enrollment.mobileconfig"
|
||||||
|
let udid = model.device?.udid
|
||||||
busy = true
|
busy = true
|
||||||
downloadStatus = "Lade von mdm.rebreak.org …"
|
downloadStatus = "Lade von mdm.rebreak.org …"
|
||||||
Task {
|
Task {
|
||||||
@ -204,14 +222,36 @@ struct EnrollView: View {
|
|||||||
guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else {
|
guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else {
|
||||||
throw URLError(.badURL)
|
throw URLError(.badURL)
|
||||||
}
|
}
|
||||||
let (data, response) = try await URLSession.shared.data(from: url)
|
var request = URLRequest(url: url)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
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)
|
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 {
|
await MainActor.run {
|
||||||
localPath = dest
|
localPath = dest
|
||||||
downloadStatus = "Geladen: \(dest) (\(data.count) Bytes)"
|
downloadStatus = "Geladen: \(dest) (\(profileData.count) Bytes)"
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
runInstallFlow(path: dest)
|
runInstallFlow(path: dest)
|
||||||
@ -220,6 +260,7 @@ struct EnrollView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
busy = false
|
busy = false
|
||||||
downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)"
|
downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
enrollError = "MDM-Server nicht erreichbar (\(error.localizedDescription)). Bitte später erneut versuchen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,14 @@ struct SuperviseView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startIfNeeded() {
|
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 {
|
if model.supervisionLog.isEmpty && !model.supervisionRunning && model.supervisionError == nil {
|
||||||
startSupervise()
|
startSupervise()
|
||||||
}
|
}
|
||||||
@ -125,9 +133,12 @@ struct SuperviseView: View {
|
|||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = Task { @MainActor in
|
task = Task { @MainActor in
|
||||||
do {
|
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(
|
_ = try await SuperviseRunner.supervise(
|
||||||
organizationName: "ReBreak",
|
organizationName: "ReBreak",
|
||||||
force: true,
|
force: false,
|
||||||
verbose: model.showAdvancedLogs
|
verbose: model.showAdvancedLogs
|
||||||
) { line in
|
) { line in
|
||||||
model.supervisionLog.append(line)
|
model.supervisionLog.append(line)
|
||||||
|
|||||||
@ -325,6 +325,14 @@ func commitViaMCInstall(conn *device.Conn, certDER []byte, orgName string, logf
|
|||||||
})
|
})
|
||||||
mc.Close()
|
mc.Close()
|
||||||
if err != nil {
|
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
|
lastErr = err
|
||||||
logf(" · attempt %d/%d: Supervise failed: %v", attempt, maxAttempts, err)
|
logf(" · attempt %d/%d: Supervise failed: %v", attempt, maxAttempts, err)
|
||||||
time.Sleep(retryDelay)
|
time.Sleep(retryDelay)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user