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>
293 lines
14 KiB
Swift
293 lines
14 KiB
Swift
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 (~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
|
||
}
|
||
}
|
||
}
|
||
}
|