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

201 lines
8.1 KiB
Swift

import SwiftUI
struct WelcomeView: View {
@Environment(WizardModel.self) private var model
@State private var detecting = false
@State private var error: String?
@State private var pollTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 24) {
Image(systemName: "iphone.gen3")
.font(.system(size: 80))
.foregroundStyle(.tint)
Text("iPhone via USB verbinden")
.font(.title)
.bold()
Text("Stecke dein iPhone per USB-C-Kabel an deinen Mac. Falls ein „Diesem Computer vertrauen?\"-Dialog erscheint, tippe auf **Vertrauen** + gib deinen iPhone-Code ein.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, 40)
if let device = model.device {
deviceCard(device)
} else if detecting {
ProgressView("Suche iPhone …")
.progressViewStyle(.circular)
} else if let error {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(error).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
}
HStack(spacing: 12) {
Button("Erneut suchen") { startDetection() }
.buttonStyle(.bordered)
.disabled(detecting)
Button(nextButtonLabel) { handleNext() }
.buttonStyle(.borderedProminent)
.disabled(model.device == nil)
}
}
.padding(40)
.onAppear { startDetection() }
.onDisappear { pollTask?.cancel() }
}
private var nextButtonLabel: String {
if model.device?.isFullyBound == true {
return "Weiter → Schutz aktivieren"
}
if model.device?.isOwnedByReBreak == true {
return "Weiter → MDM neu enrollen"
}
return "Weiter"
}
private func handleNext() {
// Smart-Resume mit echter Validation:
// - isFullyBound (supervised + recent MDM-ack) skip zu Configure
// - isOwnedByReBreak aber MDM-channel tot skip zu Enroll
// - sonst normaler Wizard-Flow
if model.device?.isFullyBound == true {
model.goTo(.configure)
} else if model.device?.isOwnedByReBreak == true {
model.goTo(.enroll)
} else {
model.advance()
}
}
private func deviceCard(_ d: DeviceState) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(d.deviceName).font(.headline)
}
Text("\(d.displayModel) · iOS \(d.productVersion)")
.font(.callout).foregroundStyle(.secondary)
Text("UDID: \(d.udid)")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
if d.isFullyBound {
HStack(spacing: 6) {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text("Vollständig durch **ReBreak** gebunden")
.font(.callout)
.foregroundStyle(.green)
}
.padding(.top, 4)
if let ack = d.enrollmentStatus?.lastAckAt {
Text("Letzter MDM-Check-In: \(ack.formatted(date: .abbreviated, time: .shortened))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Wir überspringen Supervise + Enroll und gehen direkt zum Configure-Step.")
.font(.caption)
.foregroundStyle(.secondary)
} else if d.isOwnedByReBreak && !d.hasEnrollmentProfile {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.shield")
.foregroundStyle(.orange)
Text("Supervised, **aber Enrollment-Profil fehlt**")
.font(.callout)
.foregroundStyle(.orange)
}
.padding(.top, 4)
Text("iPhone braucht MDM-Profil-Installation (Step 4).")
.font(.caption)
.foregroundStyle(.secondary)
} else if d.isOwnedByReBreak {
HStack(spacing: 6) {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.orange)
Text("Supervised by ReBreak, **MDM-Kanal stumm**")
.font(.callout)
.foregroundStyle(.orange)
}
.padding(.top, 4)
if let ack = d.enrollmentStatus?.lastAckAt {
Text("Letzter Check-In: \(ack.formatted(date: .abbreviated, time: .shortened)) — älter als 30min")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Kein MDM-Check-In aufgezeichnet. iPhone neu enrollen.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else if d.isSupervised == true, let org = d.supervisorOrgName {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.shield")
.foregroundStyle(.orange)
Text("Supervised by „\(org)\" (nicht ReBreak)")
.font(.callout)
.foregroundStyle(.orange)
}
.padding(.top, 4)
Text("Wir überschreiben das beim Supervise-Step.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: 400, alignment: .leading)
.background(Color.green.opacity(0.08))
.cornerRadius(8)
}
private func startDetection() {
pollTask?.cancel()
detecting = true
error = nil
pollTask = Task {
do {
var device = try await DeviceDetector.detect()
// Smart-Resume Layer 1: supervised + by ReBreak?
let status = await DeviceDetector.readSupervisionStatus()
device.isSupervised = status.isSupervised
device.supervisorOrgName = status.organizationName
device.isFmiOn = status.findMyEnabled
// Smart-Resume Layer 2: MDM-Channel real lebendig?
// Auch wenn supervised, kann re-supervise das MDM-Enrollment
// gekillt haben. Daher DB-Real-Check + cfgutil-Ground-Truth.
if let enrollment = try? await MDMStatus.query(udid: device.udid) {
device.enrollmentStatus = enrollment
device.isEnrolled = enrollment.isEnrolled
}
// Smart-Resume Layer 3: ist das Enrollment-Profil REAL auf iPhone?
// (cfgutil-Liste User kann Profil manuell entfernt haben,
// NanoMDM-DB merkt das erst beim nächsten APNs-Cycle.)
device.installedProfileIDs = await DeviceDetector.installedProfileIDs()
device.installedAppBundleIDs = await DeviceDetector.installedAppBundleIDs()
await MainActor.run {
model.device = device
detecting = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
self.detecting = false
}
}
}
}
}