import SwiftUI struct ConfigureView: View { @Environment(WizardModel.self) private var model @State private var task: Task? var body: some View { VStack(alignment: .leading, spacing: 16) { header Text("Wizard pusht 2 MDM-Commands (silent über APNs): App wird **managed**, NEFilter-Mode aktiviert. Danach Sideload des Lock-Profils per AirDrop (User-Tap am iPhone).") .foregroundStyle(.secondary) stepList appPreStatus statusBox logViewer 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("Pre-Check: ist ReBreak-App auf iPhone? Managed?", systemImage: "magnifyingglass") Label("Mode-Auswahl: Take-Management (TF-installiert) ODER Install-Push (Ad-Hoc-IPA via Manifest)", systemImage: "arrow.triangle.branch") Label("Settings mdmSupervised=true (NEFilter-Mode)", systemImage: "shield") Label("Post-Check: ManagedApplicationList Query — managed verified?", systemImage: "checkmark.seal") Label("Sideload Lock-Profile per AirDrop", systemImage: "paperplane") } .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 : .orange) Text(installed ? "ReBreak-App ist installiert (cfgutil) — Mode: Take-Management" : "ReBreak-App NICHT installiert — Mode: Install-Push (Manifest)") .font(.callout) } .padding(8) .background((installed ? Color.green : Color.orange).opacity(0.08)) .cornerRadius(6) } @ViewBuilder private var statusBox: some View { if model.configureRunning { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Sende Commands an NanoMDM …") } .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("3 Commands erfolgreich enqueued. iPhone empfängt via APNs (~5–30 Sekunden).") } .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 let path = sideloadProfilePath, !model.configureRunning, model.configureError == nil, !model.configureLog.isEmpty { Button("Lock-Profile per AirDrop senden") { sendViaAirDrop(path: path) } .buttonStyle(.borderedProminent) Button("…im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } .buttonStyle(.bordered) } if model.configureError != nil { Button("Neu versuchen") { startConfigure() } .buttonStyle(.bordered) } Button("Schutz ist aktiv → Fertig") { model.advance() } .buttonStyle(.borderedProminent) .disabled(model.configureRunning || model.configureLog.isEmpty || model.configureError != nil) } } /// 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 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() // Mode-Auswahl: wenn App schon installed → Take-Management, // sonst → Install-Push via Manifest. let appAlreadyInstalled = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true let modeLabel = appAlreadyInstalled ? "Take-Management (App schon installiert, nur managed-state setzen)" : "Install-Push via Manifest (App nicht installiert, Ad-Hoc-IPA pushen)" model.configureLog.append("→ Mode: \(modeLabel)") model.configureLog.append("→ [1/2] MDM-Push InstallApplication …") let r1: String if appAlreadyInstalled { r1 = try await MDMClient.takeManagement(udid: udid) } else { r1 = try await MDMClient.installApp(udid: udid) } model.configureLog.append("✓ enqueued: \(r1.prefix(80))") model.configureLog.append("→ [2/2] MDM-Push Settings mdmSupervised=true …") let r2 = try await MDMClient.setSupervisedMode(udid: udid) model.configureLog.append("✓ enqueued: \(r2.prefix(80))") model.configureLog.append("") model.configureLog.append("Beide MDM-Pushes enqueued. Warte 30s und re-check ob iPhone sie acked …") // 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 { model.configureLog.append("✓ iPhone hat ge-acked (\(lastAckAfter!.formatted(date: .omitted, time: .standard))).") // Post-Check 1: cfgutil refresh — ist App jetzt installiert? let appsAfter = await DeviceDetector.installedAppBundleIDs() let isAppInstalled = appsAfter.contains("org.rebreak.app") model.configureLog.append(isAppInstalled ? "✓ ReBreak-App jetzt auf iPhone (cfgutil)." : "⚠ ReBreak-App noch nicht auf iPhone — iPhone lädt evtl. noch (IPA = 19.6MB).") // Post-Check 2: ManagedApplicationList-Query — ist App managed? model.configureLog.append("→ Post-Check: ManagedApplicationList query …") do { if let isManaged = try await MDMClient.checkAppIsManaged(udid: udid) { model.configureLog.append(isManaged ? "✓ ReBreak ist MANAGED. App nicht löschbar durch User." : "⚠ ReBreak ist installiert aber NICHT managed.") model.device?.isManaged = isManaged } else { model.configureLog.append("⚠ iPhone hat Managed-Query nicht (rechtzeitig) ge-acked.") } } catch { model.configureLog.append("⚠ Post-Check fehlgeschlagen: \(error.localizedDescription)") } } else { model.configureLog.append("✗ Kein neuer Ack nach 30s. Push-Zeitstempel: \(pushStartTime.formatted(date: .omitted, time: .standard)), letzter Ack: \(lastAckAfter?.formatted(date: .omitted, time: .standard) ?? "nie").") throw NSError(domain: "Binder", code: 2, userInfo: [NSLocalizedDescriptionKey: "iPhone hat 30s lang keine MDM-Commands abgeholt — MDM-Channel tot. Step 4 wiederholen."]) } model.configureLog.append("") model.configureLog.append("→ Sideload-Step: Lock-Profile per AirDrop ans iPhone schicken …") model.configureLog.append(" Datei: \(sideloadProfilePath ?? "(nicht gefunden)")") model.configureLog.append(" Am iPhone: Profil-Dialog akzeptieren → Settings → Profil installieren") model.device?.isFilterActive = true // wird's nach sideload sein model.configureRunning = false } catch { model.configureLog.append("✗ Fehler: \(error.localizedDescription)") model.configureError = error.localizedDescription model.configureRunning = false } } } }