chahinebrini 2cb1f8ad6e feat(binder-mac): SwiftUI Wizard für Self-Bind End-to-End-Flow
apps/rebreak-binder-mac/ — neue macOS-App die User durch den kompletten
Self-Bind-Prozess führt: Welcome → Preflight → Supervise → Enroll →
Configure (MDM-Push + Pre/Post-Check) → Sideload Lock-Profile (AirDrop).

3-Layer Smart-Resume: supervised? + Enrollment-Profil installed (cfgutil
Ground-Truth)? + MDM-Ack fresh (NanoMDM-DB via ssh+psql)?

Services: DeviceDetector (ideviceinfo + cfgutil), SuperviseRunner
(spawnt supervise-magic CLI), MDMClient (PUT /v1/enqueue?push=1, Apple
XML-Plist, identisch zum server-watcher-Format), MDMStatus (DB-Real-
Check + ManagedApplicationList-Result-Read).

Plus:
- fix(supervise-magic): EOF nach ProcessMessage Response (ErrorCode=0)
  ist Success, nicht Error — vermeidet false-fail bei iPhone-Restore-
  Reboot
- feat(mdm-profiles): rebreak-content-filter-mdm.mobileconfig als
  MDM-Push-Variante (ohne ConsentText, ohne globales allowAppRemoval=
  false — per-app via managed-state)

End-to-End validiert: App-Push via Ad-Hoc-Manifest (silent), Managed-
State via ManagedApplicationList-Query, NEFilter-Mode nach App-Force-
Quit, Lock-Profile non-removable nach Sideload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:37:14 +02:00

293 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct ConfigureView: View {
@Environment(WizardModel.self) private var model
@State private var task: Task<Void, Never>?
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 (~530 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
}
}
}
}