import SwiftUI struct ConfigureView: View { @Environment(WizardModel.self) private var model @State private var task: Task? @State private var needsPushRetry = false @State private var lockProfileConfirmed = false @State private var configureReady = false @State private var appPushDone = false @State private var backendValidationDone = false @State private var didAutoFinish = false private let sideloadProfileID = "org.rebreak.protection.contentfilter.sideload" var body: some View { VStack(alignment: .leading, spacing: 16) { header Text("Wir richten den Schutz jetzt automatisch ein: App-Setup per Push und anschließend Lock-Profil (non-removable) mit automatischer Prüfung.") .foregroundStyle(.secondary) TransferAnimationView( leftSymbol: "server.rack", rightSymbol: "iphone.gen3", title: "App-Setup", subtitle: appPushDone ? "ReBreak-App Push/Management bestätigt." : "ReBreak-Server pusht App-Setup auf das iPhone.", isActive: model.configureRunning && !appPushDone, isDone: appPushDone ) TransferAnimationView( leftSymbol: "iphone.gen3", rightSymbol: "server.rack", title: "Lock + DNS Validierung", subtitle: backendValidationDone ? "Lock-Profil aktiv und Backend-Check-In ist frisch." : "Warte auf Lock-Profil und anschließende Backend-Bestätigung.", isActive: model.configureRunning && appPushDone && !backendValidationDone, isDone: backendValidationDone ) stepList appPreStatus statusBox if model.showAdvancedLogs { logViewer } Button(model.showAdvancedLogs ? "Details ausblenden" : "Details anzeigen") { model.showAdvancedLogs.toggle() } .buttonStyle(.borderless) .foregroundStyle(.secondary) Spacer() navigationBar } .padding(40) .onAppear { startIfNeeded() } .onDisappear { task?.cancel() } } private var header: some View { HStack { Image(systemName: "shield.lefthalf.filled") .font(.system(size: 30)) .foregroundStyle(.tint) Text("Schutz aktivieren") .font(.title).bold() } } private var stepList: some View { VStack(alignment: .leading, spacing: 6) { Label("Automatischer Pre-Check", systemImage: "magnifyingglass") Label("App-Setup + Managed-Status per Push", systemImage: "arrow.triangle.branch") Label("Lock-Profil (non-removable) anwenden", systemImage: "paperplane") Label("Automatische Verifikation", systemImage: "checkmark.seal") } .font(.callout) .foregroundStyle(.secondary) } /// Pre-Check Status der ReBreak-App auf dem iPhone. private var appPreStatus: some View { let installed = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true return HStack(spacing: 8) { Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle") .foregroundStyle(installed ? .green : .secondary) Text(installed ? "ReBreak-App ist bereits installiert. Wir setzen jetzt den Managed-Status." : "ReBreak-App noch nicht lokal sichtbar. Wir installieren sie jetzt automatisch per Push.") .font(.callout) } .padding(8) .background((installed ? Color.green : Color.blue).opacity(0.08)) .cornerRadius(6) } @ViewBuilder private var statusBox: some View { if model.configureRunning { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Automatischer Schutz-Flow läuft …") } .padding(10) .background(Color.blue.opacity(0.08)) .cornerRadius(6) } else if let err = model.configureError { HStack(alignment: .top, spacing: 8) { Image(systemName: "xmark.octagon").foregroundStyle(.red) Text(err).font(.callout) } .padding(10) .background(Color.red.opacity(0.08)) .cornerRadius(6) } else if !model.configureLog.isEmpty { HStack { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) Text("Schutz vollständig validiert. Du kannst abschließen.") } .padding(10) .background(Color.green.opacity(0.08)) .cornerRadius(6) } } private var logViewer: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(Array(model.configureLog.enumerated()), id: \.offset) { idx, line in Text(line) .font(.system(.caption, design: .monospaced)) .foregroundStyle(line.hasPrefix("✗") ? .red : .secondary) .id(idx) } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) } .background(Color.black.opacity(0.04)) .cornerRadius(6) .frame(maxHeight: 200) .onChange(of: model.configureLog.count) { _, newCount in if newCount > 0 { proxy.scrollTo(newCount - 1, anchor: .bottom) } } } } private var navigationBar: some View { HStack { Button("Zurück") { model.goTo(.enroll) } .buttonStyle(.bordered) .disabled(model.configureRunning) Spacer() if configureReady { Text("Schutz bestätigt. Abschluss wird automatisch geöffnet …") .font(.callout) .foregroundStyle(.secondary) Button("Jetzt zu Fertig") { model.advance() } .buttonStyle(.borderedProminent) } else { Text("Bitte kurz warten …") .font(.callout) .foregroundStyle(.secondary) } } } /// Pfad zur Sideload-Profile-Datei (nicht zur MDM-Push-Variante). /// User dropt die per AirDrop an's iPhone. private var sideloadProfilePath: String? { let home = FileManager.default.homeDirectoryForCurrentUser.path let candidates = [ "\(home)/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig", ] return candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) } /// Öffnet macOS' NSSharingServicePicker mit AirDrop-Service vorausgewählt. /// User klickt das eigene iPhone an → File wird übertragen → iPhone fragt nach Install. private func sendViaAirDrop(path: String) { let url = URL(fileURLWithPath: path) guard let service = NSSharingService(named: .sendViaAirDrop) else { model.configureLog.append("⚠ AirDrop-Service nicht verfügbar — manuell per Finder teilen.") return } if service.canPerform(withItems: [url]) { service.perform(withItems: [url]) model.configureLog.append("→ AirDrop-Sheet geöffnet — wähle dein iPhone aus.") } else { model.configureLog.append("⚠ AirDrop kann diese Datei nicht senden — manuell per Finder.") NSWorkspace.shared.activateFileViewerSelecting([url]) } } private func startIfNeeded() { if model.configureLog.isEmpty && !model.configureRunning && model.configureError == nil { startConfigure() } } private func startConfigure() { guard let udid = model.device?.udid else { model.configureError = "Kein Device — bitte zurück zu Step 1." return } model.configureLog = [] model.configureError = nil model.configureRunning = true needsPushRetry = false lockProfileConfirmed = false configureReady = false appPushDone = false backendValidationDone = false didAutoFinish = false task?.cancel() task = Task { @MainActor in do { // PRE-FLIGHT: real-check ob iPhone überhaupt enrolled ist + check-in macht model.configureLog.append("→ Pre-Flight: NanoMDM-Enrollment-Status …") let status = try await MDMStatus.query(udid: udid) if !status.isEnrolled { throw NSError(domain: "Binder", code: 1, userInfo: [NSLocalizedDescriptionKey: "iPhone ist NICHT in NanoMDM enrolled. Bitte Step 4 (Enroll) wiederholen."]) } let pending = status.pendingCommandCount if let ack = status.lastAckAt { let ageMin = Int(Date().timeIntervalSince(ack) / 60) model.configureLog.append("✓ enrolled · letzter Ack vor \(ageMin) min · \(pending) pending") if ageMin > 30 { model.configureLog.append("⚠ Letzter Check-In ist alt (>30min). iPhone reagiert evtl. nicht.") model.configureLog.append("⚠ Falls Commands nach 1min nicht ausgeführt: Step 4 (Enroll) wiederholen.") } } else { model.configureLog.append("⚠ Noch nie ge-acked. Enrollment vermutlich tot — Step 4 wiederholen.") } model.configureLog.append("→ Ping NanoMDM …") let version = try await MDMClient.ping() model.configureLog.append("✓ NanoMDM \(version.trimmingCharacters(in: .whitespacesAndNewlines))") // Marker für Post-Flight: alle Acks NACH diesem Zeitstempel // gehören zu unseren Commands. NanoMDM's enrollment_queue.active // bleibt nach Ack auf true — daher zählen wir command_results. let pushStartTime = Date() // Harte Variante fuer robuste Tests: // 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 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: "MDM-RemoveApplication wurde gepusht aber iPhone hat App nicht entfernt. Bitte Step 4 (Enroll) wiederholen."]) } model.configureLog.append("✓ Vorhandene ReBreak-App entfernt.") } else { model.configureLog.append("→ ReBreak-App nicht vorhanden, starte frischen Install-Push.") } for attempt in 1...2 { model.configureLog.append("→ [1/2] Push-Versuch \(attempt): InstallApplication …") let r1 = try await MDMClient.installApp(udid: udid) model.configureLog.append("✓ enqueued: \(r1.prefix(80))") model.configureLog.append("→ [2/2] Push-Versuch \(attempt): Settings mdmSupervised=true …") let r2 = try await MDMClient.setSupervisedMode(udid: udid) model.configureLog.append("✓ enqueued: \(r2.prefix(80))") model.configureLog.append("") model.configureLog.append("Warte 30s und prüfe automatische Rückmeldung …") // POST-FLIGHT: 30s warten + checken ob neue Acks NACH pushStartTime da sind try? await Task.sleep(for: .seconds(30)) let after = try await MDMStatus.query(udid: udid) let lastAckAfter = after.lastAckAt let hasNewAck = (lastAckAfter ?? .distantPast) > pushStartTime if !hasNewAck { needsPushRetry = true model.configureLog.append("⚠ Kein neuer Ack erkannt (Versuch \(attempt)).") if attempt == 2 { throw NSError(domain: "Binder", code: 2, userInfo: [NSLocalizedDescriptionKey: "iPhone hat keine Pushes abgeholt. Bitte Enrollment-Verbindung prüfen."]) } continue } model.configureLog.append("✓ iPhone hat ge-acked (\(lastAckAfter!.formatted(date: .omitted, time: .standard))).") let appsAfter = await DeviceDetector.installedAppBundleIDs() let isAppInstalled = appsAfter.contains("org.rebreak.app") model.configureLog.append(isAppInstalled ? "✓ ReBreak-App ist auf dem iPhone." : "⚠ ReBreak-App noch nicht sichtbar (Versuch \(attempt)).") model.configureLog.append("→ Verifiziere Managed-Status …") let managed = try await MDMClient.checkAppIsManaged(udid: udid) if isAppInstalled, managed == true { model.device?.isManaged = true needsPushRetry = false appPushDone = true break } needsPushRetry = true if attempt == 2 { throw NSError(domain: "Binder", code: 3, userInfo: [NSLocalizedDescriptionKey: "App-Setup konnte nicht stabil verifiziert werden. Bitte Schritt erneut starten."]) } model.configureLog.append("⚠ Automatischer Retry läuft …") } model.configureLog.append("") model.configureLog.append("→ [3/3] Installiere non-removable Lock-Profil …") guard let profilePath = sideloadProfilePath else { throw NSError(domain: "Binder", code: 4, userInfo: [NSLocalizedDescriptionKey: "Lock-Profil-Datei nicht gefunden."]) } do { try await DeviceDetector.installProfileSilently(path: profilePath) model.configureLog.append("✓ Lock-Profil via USB installiert.") } catch { // Falls cfgutil zwar Fehler liefert, das Profil aber dennoch // bereits installiert wurde, kein AirDrop mehr öffnen. let alreadyInstalled = await waitForLockProfileInstalled(maxChecks: 4, intervalSeconds: 2) if alreadyInstalled { model.configureLog.append("✓ Lock-Profil wurde trotz USB-Fehler erkannt. Kein AirDrop nötig.") } else { model.configureLog.append("⚠ USB-Install nicht möglich: \(error.localizedDescription)") model.configureLog.append("→ Öffne AirDrop-Fallback für das Lock-Profil …") sendViaAirDrop(path: profilePath) } } let lockInstalled = await waitForLockProfileInstalled() if !lockInstalled { throw NSError(domain: "Binder", code: 5, userInfo: [NSLocalizedDescriptionKey: "Lock-Profil wurde noch nicht erkannt. Bitte iPhone-Dialog abschließen."]) } lockProfileConfirmed = true model.device?.isFilterActive = true model.configureLog.append("→ Validiere frischen Backend-Check-In …") let backendOk = await waitForFreshBackendStatus(udid: udid) if !backendOk { throw NSError(domain: "Binder", code: 6, userInfo: [NSLocalizedDescriptionKey: "Backend-Bestätigung für aktiven Schutz fehlt noch. Bitte kurz warten und erneut versuchen."]) } backendValidationDone = true configureReady = true model.configureLog.append("✓ Lock-Profil ist aktiv erkannt.") model.configureLog.append("✓ Backend-Status bestätigt aktiven Schutz.") model.configureRunning = false triggerAutomaticFinish() } catch { model.configureLog.append("✗ Fehler: \(error.localizedDescription)") model.configureError = error.localizedDescription model.configureRunning = false } } } private func waitForLockProfileInstalled(maxChecks: Int = 40, intervalSeconds: UInt64 = 3) async -> Bool { for _ in 0.. Bool { for _ in 0..<20 { let installed = await DeviceDetector.installedAppBundleIDs().contains("org.rebreak.app") if installed == expectedInstalled { return true } try? await Task.sleep(for: .seconds(2)) } return false } private func waitForFreshBackendStatus(udid: String) async -> Bool { for _ in 0..<30 { if let status = try? await MDMStatus.query(udid: udid) { model.device?.enrollmentStatus = status if status.isEnrolled && status.isFresh { return true } } try? await Task.sleep(for: .seconds(3)) } return false } @MainActor private func triggerAutomaticFinish() { guard configureReady, !didAutoFinish else { return } didAutoFinish = true Task { @MainActor in try? await Task.sleep(for: .seconds(0.8)) model.advance() } } }