diff --git a/apps/rebreak-binder-mac/.gitignore b/apps/rebreak-binder-mac/.gitignore
new file mode 100644
index 0000000..60484e2
--- /dev/null
+++ b/apps/rebreak-binder-mac/.gitignore
@@ -0,0 +1,15 @@
+# xcodegen-generated
+*.xcodeproj/
+
+# Xcode build artifacts
+build/
+DerivedData/
+*.xcuserstate
+xcuserdata/
+
+# Local config (API keys)
+config.local.json
+.env
+
+# macOS
+.DS_Store
diff --git a/apps/rebreak-binder-mac/README.md b/apps/rebreak-binder-mac/README.md
new file mode 100644
index 0000000..fb1bded
--- /dev/null
+++ b/apps/rebreak-binder-mac/README.md
@@ -0,0 +1,92 @@
+# ReBreak Binder (Mac)
+
+End-User-Wizard für Self-Binding eines iPhones an ReBreak. Macht in einem 5-Step-Flow:
+
+1. **Welcome** — Detect iPhone via USB (lockdownd)
+2. **Pre-Flight** — Find-My-iPhone + Stolen-Device-Protection prüfen/ausschalten
+3. **Supervise** — `supervise-magic` Plist-Inject + Reboot (kein Erase)
+4. **Enroll** — MDM-Enrollment-Profile auf iPhone installieren
+5. **Configure** — NanoMDM pusht: Lock-Profile + Take-Management + Settings(mdmSupervised=true)
+
+Resultat: iPhone supervised by "ReBreak", App nicht löschbar, NEFilter aktiv (kein User-Toggle in Settings).
+
+**Pre-Requirement**: ReBreak-App muss VOR Wizard-Start aus TestFlight installiert sein. Wizard nutzt `InstallApplication` mit `ChangeManagementState: Managed` (kein ManifestURL nötig, kein ABM-Account). Auto-Install via MDM-Push ist Phase 2 (braucht ABM oder Manifest-Hosting).
+
+## Status
+
+🚧 Phase 1 — Skelett. Nur lokal nutzbar (User+Olfa+Dev-iPhones).
+
+## Voraussetzungen
+
+| Tool | Wie |
+|---|---|
+| Xcode 26+ | App Store |
+| xcodegen | `brew install xcodegen` |
+| libimobiledevice | `brew install libimobiledevice` |
+| supervise-magic binary | aus `../../ops/mdm/supervise-magic/` (`make build`) |
+| cfgutil | Apple Configurator (App Store) → `/Applications/Apple Configurator.app/Contents/MacOS/cfgutil` für silent profile install |
+
+## Build
+
+```bash
+cd apps/rebreak-binder-mac
+
+# Einmalig: dependencies + supervise-magic-binary bauen
+(cd ../../ops/mdm/supervise-magic && make tidy && make build)
+
+# Xcode-Project generieren (oder neu generieren nach project.yml Änderungen)
+xcodegen generate
+
+# Bauen + öffnen
+open RebreakBinder.xcodeproj
+# → ⌘R in Xcode
+```
+
+Oder CLI-only:
+
+```bash
+xcodebuild -project RebreakBinder.xcodeproj -scheme RebreakBinder -configuration Debug build
+open build/Debug/RebreakBinder.app
+```
+
+## Config (lokal)
+
+NanoMDM-API-Key braucht die App für Step 5 (Configure). Lege ein lokales config-file an:
+
+```bash
+cat > ~/.config/rebreak-binder/config.json <<'EOF'
+{
+ "mdmServer": "https://mdm.rebreak.org",
+ "mdmUser": "nanomdm",
+ "mdmApiKey": "<32-char-hex from /root/.nanomdm_admin_pass on rebreak-mdm>"
+}
+EOF
+chmod 600 ~/.config/rebreak-binder/config.json
+```
+
+Production-Version legt das in Keychain ab — heute reicht plain JSON.
+
+## TODOs (post-Skelett)
+
+- [ ] **Lock-Profile-Refactor**: `allowAppRemoval=false` GLOBAL raus aus `rebreak-content-filter-sideload.mobileconfig`. Per-App-Lock kommt über Managed-App-State (MDM `InstallApplication` mit `ChangeManagementState: Managed` → iOS deaktiviert App-Wackel-„X" automatisch für managed apps). Andere Apps bleiben löschbar (bessere UX).
+- [ ] App-Versions-Mgmt: `InstallApplication`-Manifest-URL-Pointer auf latest IPA (siehe `ops/mdm/PHASES.md` Phase F.5)
+- [ ] Trustee-Setup-Optional in DoneView (Email an Vertrauensperson)
+- [ ] 7-Tage-Cooldown-Persistenz (lokale SQLite oder Backend)
+- [ ] Code-Signing + Notarization (Developer-ID)
+- [ ] Backend `/api/binder/*` Endpoints — Mac-App spricht heute MDM-Server direkt
+
+## Architektur
+
+- **SwiftUI macOS-App** mit `@Observable` State-Machine (`WizardModel`)
+- **Services** sind dünne Wrapper um:
+ - `ideviceinfo` (libimobiledevice) — Device-Detection
+ - `supervise-magic` Go-CLI — Supervise + Status-Check
+ - `cfgutil` (optional, Apple Configurator 2) — Silent Profile-Install
+ - NanoMDM HTTP-API (`mdm.rebreak.org`) — InstallProfile + Settings-Commands
+- **Kein Backend-Account-Login** für MVP — API-Key in lokalem Config
+
+## Sicherheit
+
+- API-Key sollte langfristig in Keychain (heute: plain JSON, chmod 600)
+- App ist **unsigned** für lokales Testen — Gatekeeper-Warning beim ersten Öffnen
+- Process-Spawn von go-binaries braucht **disabled App-Sandbox** (gesetzt in `project.yml`)
diff --git a/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift b/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift
new file mode 100644
index 0000000..bc3a194
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+struct DeviceState: Equatable {
+ var udid: String
+ var productType: String
+ var productVersion: String
+ var deviceName: String
+
+ var isFmiOn: Bool? // nil = unknown / not yet checked
+ var isSdpOn: Bool?
+ var isSupervised: Bool?
+ var supervisorOrgName: String? // z.B. "ReBreak" wenn schon by uns gebunden
+ var isEnrolled: Bool?
+ var enrollmentStatus: EnrollmentStatus? // Real-Check via NanoMDM-DB
+ var installedProfileIDs: [String] = [] // cfgutil-list — Ground-Truth!
+ var installedAppBundleIDs: [String] = [] // cfgutil installedApps — für Pre-Check
+ var isManaged: Bool?
+ var isFilterActive: Bool?
+
+ /// Identifier des Enrollment-Profils — muss mit ops/mdm/enrollment-profile matchen.
+ static let enrollmentProfileID = "org.rebreak.mdm.enrollment"
+ /// Identifier des Lock-Sideload-Profils.
+ static let lockProfileID = "org.rebreak.protection.contentfilter.sideload"
+
+ var isOwnedByReBreak: Bool {
+ (isSupervised == true) && (supervisorOrgName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame)
+ }
+
+ /// Ground-Truth: ist das Enrollment-Profil aktuell auf dem iPhone installiert?
+ /// (cfgutil-Liste statt NanoMDM-DB — DB hat Lag wenn User Profil manuell entfernt.)
+ var hasEnrollmentProfile: Bool {
+ installedProfileIDs.contains(Self.enrollmentProfileID)
+ }
+
+ var hasLockProfile: Bool {
+ installedProfileIDs.contains(Self.lockProfileID)
+ }
+
+ /// True nur wenn iPhone supervised durch uns IST, das Enrollment-Profil tatsächlich
+ /// installiert ist, UND MDM-Channel kürzlich aktiv war.
+ var isFullyBound: Bool {
+ isOwnedByReBreak && hasEnrollmentProfile && (enrollmentStatus?.isFresh == true)
+ }
+
+ var displayModel: String {
+ Self.modelMap[productType] ?? productType
+ }
+
+ private static let modelMap: [String: String] = [
+ "iPhone18,4": "iPhone Air",
+ "iPhone17,1": "iPhone 16 Pro",
+ "iPhone17,2": "iPhone 16 Pro Max",
+ "iPhone17,3": "iPhone 16",
+ "iPhone17,4": "iPhone 16 Plus",
+ "iPhone16,1": "iPhone 15 Pro",
+ "iPhone16,2": "iPhone 15 Pro Max",
+ "iPhone15,4": "iPhone 15",
+ "iPhone15,5": "iPhone 15 Plus",
+ ]
+}
diff --git a/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift b/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift
new file mode 100644
index 0000000..fb1a360
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift
@@ -0,0 +1,45 @@
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class WizardModel {
+ var step: WizardStep = .welcome
+ var device: DeviceState?
+
+ var supervisionLog: [String] = []
+ var supervisionRunning: Bool = false
+ var supervisionError: String?
+
+ var enrollmentLog: [String] = []
+ var enrollmentRunning: Bool = false
+ var enrollmentError: String?
+
+ var configureLog: [String] = []
+ var configureRunning: Bool = false
+ var configureError: String?
+
+ var cooldownEndsAt: Date?
+
+ func advance() {
+ if let next = WizardStep(rawValue: step.rawValue + 1) {
+ step = next
+ }
+ }
+
+ func goTo(_ s: WizardStep) {
+ step = s
+ }
+
+ func reset() {
+ step = .welcome
+ device = nil
+ supervisionLog = []
+ enrollmentLog = []
+ configureLog = []
+ supervisionError = nil
+ enrollmentError = nil
+ configureError = nil
+ cooldownEndsAt = nil
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Models/WizardStep.swift b/apps/rebreak-binder-mac/Sources/Models/WizardStep.swift
new file mode 100644
index 0000000..e795229
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Models/WizardStep.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+enum WizardStep: Int, CaseIterable, Identifiable {
+ case welcome = 0
+ case preflight
+ case supervise
+ case enroll
+ case configure
+ case done
+
+ var id: Int { rawValue }
+
+ var title: String {
+ switch self {
+ case .welcome: return "iPhone verbinden"
+ case .preflight: return "Pre-Flight Check"
+ case .supervise: return "Supervisieren"
+ case .enroll: return "MDM-Enrollment"
+ case .configure: return "Schutz aktivieren"
+ case .done: return "Fertig"
+ }
+ }
+
+ var stepNumber: Int { rawValue + 1 }
+
+ static var total: Int { allCases.count - 1 }
+}
diff --git a/apps/rebreak-binder-mac/Sources/RebreakBinderApp.swift b/apps/rebreak-binder-mac/Sources/RebreakBinderApp.swift
new file mode 100644
index 0000000..bce8ccd
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/RebreakBinderApp.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+
+@main
+struct RebreakBinderApp: App {
+ @State private var model = WizardModel()
+
+ var body: some Scene {
+ WindowGroup("ReBreak Binder") {
+ ContentView()
+ .environment(model)
+ .frame(minWidth: 720, idealWidth: 800, minHeight: 600, idealHeight: 720)
+ }
+ .windowResizability(.contentSize)
+ .windowStyle(.titleBar)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..bbc2029
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {"alpha" : "1.000", "blue" : "0.831", "green" : "0.498", "red" : "0.180"}
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {"author" : "xcode", "version" : 1}
+}
diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..5dc4daa
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {"idiom" : "mac", "scale" : "1x", "size" : "16x16"},
+ {"idiom" : "mac", "scale" : "2x", "size" : "16x16"},
+ {"idiom" : "mac", "scale" : "1x", "size" : "32x32"},
+ {"idiom" : "mac", "scale" : "2x", "size" : "32x32"},
+ {"idiom" : "mac", "scale" : "1x", "size" : "128x128"},
+ {"idiom" : "mac", "scale" : "2x", "size" : "128x128"},
+ {"idiom" : "mac", "scale" : "1x", "size" : "256x256"},
+ {"idiom" : "mac", "scale" : "2x", "size" : "256x256"},
+ {"idiom" : "mac", "scale" : "1x", "size" : "512x512"},
+ {"idiom" : "mac", "scale" : "2x", "size" : "512x512"}
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/Contents.json b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Resources/Info.plist b/apps/rebreak-binder-mac/Sources/Resources/Info.plist
new file mode 100644
index 0000000..ecd3a7d
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Resources/Info.plist
@@ -0,0 +1,34 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ ReBreak Binder
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSHumanReadableCopyright
+ © 2026 Raynis GmbH
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift b/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift
new file mode 100644
index 0000000..83dc49c
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift
@@ -0,0 +1,160 @@
+import Foundation
+
+/// Wrapper um `ideviceinfo` aus libimobiledevice.
+/// Liest die wichtigsten Lockdown-Keys eines per USB verbundenen iPhones.
+enum DeviceDetector {
+ enum DetectorError: Error, LocalizedError {
+ case ideviceinfoMissing
+ case noDevice
+ case parseError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .ideviceinfoMissing:
+ return "ideviceinfo nicht gefunden — bitte `brew install libimobiledevice` ausführen."
+ case .noDevice:
+ return "Kein iPhone via USB erkannt. Kabel + Trust-Dialog am iPhone prüfen."
+ case .parseError(let msg):
+ return "Parse-Fehler: \(msg)"
+ }
+ }
+ }
+
+ static func detect() async throws -> DeviceState {
+ guard let bin = Paths.firstExecutable(in: Paths.ideviceinfoCandidates) else {
+ throw DetectorError.ideviceinfoMissing
+ }
+
+ async let udid = readKey(bin: bin, key: "UniqueDeviceID")
+ async let model = readKey(bin: bin, key: "ProductType")
+ async let version = readKey(bin: bin, key: "ProductVersion")
+ async let name = readKey(bin: bin, key: "DeviceName")
+
+ let (u, m, v, n) = try await (udid, model, version, name)
+ guard !u.isEmpty else { throw DetectorError.noDevice }
+
+ return DeviceState(
+ udid: u,
+ productType: m,
+ productVersion: v,
+ deviceName: n.isEmpty ? "iPhone" : n
+ )
+ }
+
+ private static func readKey(bin: String, key: String) async throws -> String {
+ let r = try await ProcessRunner.run(bin, arguments: ["-k", key])
+ if r.exitCode != 0 {
+ // ideviceinfo gibt non-zero zurück wenn kein Device da ist
+ if r.stderr.contains("No device found") || r.stderr.contains("ERROR") {
+ throw DetectorError.noDevice
+ }
+ throw DetectorError.parseError(r.stderr)
+ }
+ return r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ // MARK: - Supervision status via supervise-magic
+
+ struct SupervisionStatus: Equatable {
+ var isSupervised: Bool
+ var organizationName: String?
+ var findMyEnabled: Bool?
+
+ /// Wir betrachten das Gerät als "schon durch uns gebunden" wenn
+ /// OrganizationName matched. Case-insensitive für Robustheit.
+ var isOwnedByReBreak: Bool {
+ isSupervised && (organizationName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame)
+ }
+ }
+
+ /// Liest IsSupervised + OrganizationName via `supervise-magic cloud-config`.
+ /// Falls iPhone nicht ansprechbar oder unsupervised → returnt nil-felder.
+ static func readSupervisionStatus() async -> SupervisionStatus {
+ guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
+ return SupervisionStatus(isSupervised: false, organizationName: nil, findMyEnabled: nil)
+ }
+ var status = SupervisionStatus(isSupervised: false, organizationName: nil, findMyEnabled: nil)
+
+ // 1) cloud-config liest IsSupervised + OrganizationName direkt aus MCInstall.
+ if let r = try? await ProcessRunner.run(bin, arguments: ["cloud-config"]), r.exitCode == 0 {
+ for raw in r.stdout.split(separator: "\n") {
+ let line = String(raw)
+ if let v = parseEquals(line: line, key: "IsSupervised") {
+ status.isSupervised = (v.lowercased() == "true")
+ }
+ if let v = parseEquals(line: line, key: "OrganizationName") {
+ status.organizationName = v
+ }
+ }
+ }
+
+ // 2) `check` gibt zusätzlich FindMyEnabled.
+ if let r = try? await ProcessRunner.run(bin, arguments: ["check"]), !r.stdout.isEmpty {
+ for raw in r.stdout.split(separator: "\n") {
+ let line = String(raw)
+ if let v = parseColon(line: line, key: "FindMyEnabled") {
+ status.findMyEnabled = (v.lowercased() == "true")
+ }
+ if status.isSupervised == false, let v = parseColon(line: line, key: "IsSupervised") {
+ status.isSupervised = (v.lowercased() == "true")
+ }
+ }
+ }
+ return status
+ }
+
+ /// Parse ` Key = Value` (cloud-config Format).
+ private static func parseEquals(line: String, key: String) -> String? {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix(key) else { return nil }
+ let parts = trimmed.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
+ guard parts.count == 2, parts[0] == key else { return nil }
+ return parts[1]
+ }
+
+ /// Parse ` Key: Value` (check Format).
+ private static func parseColon(line: String, key: String) -> String? {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix(key + ":") else { return nil }
+ let after = trimmed.dropFirst(key.count + 1)
+ return after.trimmingCharacters(in: .whitespaces)
+ }
+
+ // MARK: - Installed configuration profiles via cfgutil
+
+ /// Listet alle PayloadIdentifiers der aktuell auf dem iPhone installierten
+ /// Configuration-Profiles. cfgutil-output-Format: " ".
+ /// Returnt leeres Array wenn cfgutil fehlt oder Device nicht ansprechbar.
+ static func installedProfileIDs() async -> [String] {
+ guard let cfgutil = Paths.cfgutilPath else { return [] }
+ guard let r = try? await ProcessRunner.run(cfgutil, arguments: ["--foreach", "get", "configurationProfiles"]),
+ r.exitCode == 0 else { return [] }
+ // Format: "org.rebreak.mdm.enrollment ReBreak MDM v1" (1 line per profile)
+ return r.stdout
+ .split(separator: "\n")
+ .compactMap { line -> String? in
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return nil }
+ // erstes Token = identifier
+ return trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)
+ }
+ }
+
+ /// Listet alle installierten App-Bundle-IDs.
+ /// Format: "com.bundle.id\tDisplayName (CFBundleName) vBuildNumber".
+ /// Hinweis: cfgutil hat keinen direkten "managed?"-Indikator pro App —
+ /// managed-status muss via MDM-`ManagedApplicationList`-Command geprüft werden.
+ static func installedAppBundleIDs() async -> [String] {
+ guard let cfgutil = Paths.cfgutilPath else { return [] }
+ guard let r = try? await ProcessRunner.run(cfgutil, arguments: ["--foreach", "get", "installedApps"]),
+ r.exitCode == 0 else { return [] }
+ return r.stdout
+ .split(separator: "\n")
+ .compactMap { line -> String? in
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return nil }
+ // erstes Token (vor TAB oder Space) = bundle-id
+ return trimmed.split(whereSeparator: { $0 == "\t" || $0 == " " }).first.map(String.init)
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Services/MDMClient.swift b/apps/rebreak-binder-mac/Sources/Services/MDMClient.swift
new file mode 100644
index 0000000..a9d9216
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/MDMClient.swift
@@ -0,0 +1,229 @@
+import Foundation
+
+/// HTTP-Client für NanoMDM (https://mdm.rebreak.org).
+/// Schickt Commands via POST /v1/enqueue/ mit Basic-Auth.
+///
+/// Body-Format: Apple-XML-Plist mit Top-Level-Dict {CommandUUID, Command}.
+/// (NICHT JSON — NanoMDM erwartet Plist und versucht den Body als solches zu parsen.)
+///
+/// Config wird aus ~/.config/rebreak-binder/config.json gelesen.
+struct MDMConfig: Codable {
+ var mdmServer: String
+ var mdmUser: String
+ var mdmApiKey: String
+}
+
+enum MDMClientError: Error, LocalizedError {
+ case configMissing(String)
+ case configMalformed(String)
+ case http(Int, String)
+ case profileMissing(String)
+ case encoding
+
+ var errorDescription: String? {
+ switch self {
+ case .configMissing(let path):
+ return "MDM-Config nicht gefunden: \(path). Bitte README → 'Config (lokal)'."
+ case .configMalformed(let msg):
+ return "MDM-Config ist kaputt: \(msg)"
+ case .http(let status, let body):
+ return "NanoMDM HTTP \(status): \(body)"
+ case .profileMissing(let path):
+ return "Profile-File nicht gefunden: \(path)"
+ case .encoding:
+ return "Plist-Encoding fehlgeschlagen."
+ }
+ }
+}
+
+enum MDMClient {
+ static let configPath: String = {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return "\(home)/.config/rebreak-binder/config.json"
+ }()
+
+ static let lockProfilePathCandidates: [String] = {
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ return [
+ // MDM-Push-Variante OHNE ConsentText + OHNE allowAppRemoval=false
+ // (App-Removal-Block kommt über managed-app-state, nicht global)
+ "\(home)/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig",
+ ]
+ }()
+
+ static func loadConfig() throws -> MDMConfig {
+ let url = URL(fileURLWithPath: configPath)
+ guard FileManager.default.fileExists(atPath: configPath) else {
+ throw MDMClientError.configMissing(configPath)
+ }
+ do {
+ let data = try Data(contentsOf: url)
+ return try JSONDecoder().decode(MDMConfig.self, from: data)
+ } catch {
+ throw MDMClientError.configMalformed(error.localizedDescription)
+ }
+ }
+
+ struct EnqueueResult {
+ let commandUUID: String
+ let responseBody: String
+ }
+
+ /// Wraps `command` in NanoMDM's expected envelope und schickt's an
+ /// `/v1/enqueue/?push=1` (sofort-APNs, wie der server-watcher).
+ /// Content-Type: `application/x-plist`. Returnt UUID + body.
+ static func enqueue(udid: String, command: [String: Any]) async throws -> EnqueueResult {
+ let cfg = try loadConfig()
+ guard let url = URL(string: "\(cfg.mdmServer)/v1/enqueue/\(udid)?push=1") else {
+ throw MDMClientError.configMalformed("server URL invalid")
+ }
+
+ let cmdUUID = UUID().uuidString
+ let envelope: [String: Any] = [
+ "CommandUUID": cmdUUID,
+ "Command": command,
+ ]
+ let plistData: Data
+ do {
+ plistData = try PropertyListSerialization.data(fromPropertyList: envelope, format: .xml, options: 0)
+ } catch {
+ throw MDMClientError.encoding
+ }
+
+ var req = URLRequest(url: url)
+ req.httpMethod = "PUT" // wie der server-watcher
+ req.setValue("application/x-plist", forHTTPHeaderField: "Content-Type")
+ let creds = "\(cfg.mdmUser):\(cfg.mdmApiKey)"
+ guard let credData = creds.data(using: .utf8) else { throw MDMClientError.encoding }
+ req.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization")
+ req.httpBody = plistData
+
+ let (data, response) = try await URLSession.shared.data(for: req)
+ let body = String(data: data, encoding: .utf8) ?? ""
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ let status = (response as? HTTPURLResponse)?.statusCode ?? -1
+ throw MDMClientError.http(status, body)
+ }
+ return EnqueueResult(commandUUID: cmdUUID, responseBody: body)
+ }
+
+ // MARK: - High-level commands
+
+ /// Take Management der bereits-installierten ReBreak-App (TestFlight).
+ /// `ChangeManagementState=Managed` macht aus org.rebreak.app eine "managed app"
+ /// (kein Wackel-X mehr auf supervised iPhones).
+ /// Nur sinnvoll wenn App schon installiert ist — sonst no-op.
+ static func takeManagement(udid: String, bundleID: String = "org.rebreak.app") async throws -> String {
+ let cmd: [String: Any] = [
+ "RequestType": "InstallApplication",
+ "Identifier": bundleID,
+ "ChangeManagementState": "Managed",
+ "ManagementFlags": 0,
+ ]
+ return try await enqueue(udid: udid, command: cmd).responseBody
+ }
+
+ /// Installiert die ReBreak-App via Ad-Hoc-Manifest (gehostet auf
+ /// mdm.rebreak.org/install/manifest.plist). Auf supervised iPhones läuft
+ /// das silent — iOS lädt die IPA aus dem Manifest + installiert sie direkt
+ /// als managed-app.
+ ///
+ /// Format identisch zum server-side `install-trigger.sh`-Watcher:
+ /// nur ManifestURL + ManagementFlags (keine zusätzlichen Felder).
+ static func installApp(
+ udid: String,
+ manifestURL: String = "https://mdm.rebreak.org/install/manifest.plist"
+ ) async throws -> String {
+ let cmd: [String: Any] = [
+ "RequestType": "InstallApplication",
+ "ManifestURL": manifestURL,
+ "ManagementFlags": 0,
+ ]
+ return try await enqueue(udid: udid, command: cmd).responseBody
+ }
+
+ /// Pusht ManagedApplicationList-Query an iPhone (welche Apps managed?).
+ /// Returnt die generierte CommandUUID — Caller liest danach via
+ /// `MDMStatus.readCommandResult(udid:, commandUUID:)` das Ergebnis.
+ static func queryManagedAppList(udid: String, bundleIDs: [String] = ["org.rebreak.app"]) async throws -> String {
+ let cmd: [String: Any] = [
+ "RequestType": "ManagedApplicationList",
+ "Identifiers": bundleIDs,
+ ]
+ return try await enqueue(udid: udid, command: cmd).commandUUID
+ }
+
+ /// Convenience: Push ManagedApplicationList, warte `waitSeconds`,
+ /// lese result aus DB, parse ob `bundleID` State=Managed hat.
+ /// Returnt nil wenn iPhone nicht ge-acked hat oder UDID nicht enrolled.
+ static func checkAppIsManaged(
+ udid: String,
+ bundleID: String = "org.rebreak.app",
+ waitSeconds: Int = 8
+ ) async throws -> Bool? {
+ let cmdUUID = try await queryManagedAppList(udid: udid, bundleIDs: [bundleID])
+ try? await Task.sleep(for: .seconds(waitSeconds))
+ guard let result = try await MDMStatus.readCommandResult(udid: udid, commandUUID: cmdUUID) else {
+ return nil // nicht ge-acked oder Channel tot
+ }
+ // Result-Plist enthält für jede App ein Dict mit Status-Field.
+ // Schnell-Check: enthält das XML "Managed"-String unter einem bundleID-Block?
+ // (Detail-parsing wäre besser, aber für MVP reicht string-search.)
+ let lowercased = result.lowercased()
+ return lowercased.contains("managed")
+ }
+
+ /// Settings-Command der die App in NEFilter-Mode schiebt (statt PacketTunnel-VPN).
+ static func setSupervisedMode(udid: String, bundleID: String = "org.rebreak.app") async throws -> String {
+ let cmd: [String: Any] = [
+ "RequestType": "Settings",
+ "Settings": [
+ [
+ "Item": "ApplicationConfiguration",
+ "Identifier": bundleID,
+ "Configuration": [
+ "mdmSupervised": true,
+ ],
+ ],
+ ],
+ ]
+ return try await enqueue(udid: udid, command: cmd).responseBody
+ }
+
+ /// Install des sideload-Lock-Profils (rebreak-content-filter-sideload.mobileconfig).
+ /// Profile-Inhalt wird als `Payload`-Data eingebettet — PropertyListSerialization
+ /// encoded es automatisch als `...` (base64).
+ static func installLockProfile(udid: String) async throws -> String {
+ guard let profilePath = lockProfilePathCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
+ throw MDMClientError.profileMissing(lockProfilePathCandidates.joined(separator: " | "))
+ }
+ let profileData = try Data(contentsOf: URL(fileURLWithPath: profilePath))
+ let cmd: [String: Any] = [
+ "RequestType": "InstallProfile",
+ "Payload": profileData,
+ ]
+ return try await enqueue(udid: udid, command: cmd).responseBody
+ }
+
+ // MARK: - Sanity check
+
+ /// Versucht den NanoMDM-Server zu pingen (`/version`-Endpoint).
+ /// Returnt z.B. "v0.9.0".
+ static func ping() async throws -> String {
+ let cfg = try loadConfig()
+ guard let url = URL(string: "\(cfg.mdmServer)/version") else {
+ throw MDMClientError.configMalformed("server URL invalid")
+ }
+ var req = URLRequest(url: url)
+ let creds = "\(cfg.mdmUser):\(cfg.mdmApiKey)"
+ guard let credData = creds.data(using: .utf8) else { throw MDMClientError.encoding }
+ req.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization")
+ let (data, response) = try await URLSession.shared.data(for: req)
+ let body = String(data: data, encoding: .utf8) ?? ""
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ let status = (response as? HTTPURLResponse)?.statusCode ?? -1
+ throw MDMClientError.http(status, body)
+ }
+ return body
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Services/MDMStatus.swift b/apps/rebreak-binder-mac/Sources/Services/MDMStatus.swift
new file mode 100644
index 0000000..3be5ffd
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/MDMStatus.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+/// Real-Check ob ein iPhone (per UDID) tatsächlich beim NanoMDM enrolled ist
+/// und wann es zuletzt Commands acked hat. Implementiert via SSH+psql gegen
+/// den rebreak-mdm-Server (Phase 1 / lokal-only).
+///
+/// Production-Pfad wäre ein dedizierter `mdm.rebreak.org/status/` REST-Endpoint —
+/// solange den NanoMDM nicht hat, ist SSH der pragmatische Weg.
+struct EnrollmentStatus: Equatable {
+ /// True wenn UDID in NanoMDM-`devices`-Tabelle vorhanden.
+ var isEnrolled: Bool
+ /// Wann das iPhone zuletzt sein Auth-Token erneuert hat (≈ enrollment-time
+ /// oder letzter MDM-handshake).
+ var tokenUpdateAt: Date?
+ /// Wann iPhone zuletzt einen Command Acknowledged hat. nil = nie.
+ var lastAckAt: Date?
+ /// Wieviele Commands aktuell in der Queue stecken + active sind.
+ var pendingCommandCount: Int
+
+ /// Heuristik: "frisch" wenn MDM-Channel kürzlich aktiv war.
+ /// Entweder iPhone hat kürzlich Command acked, ODER hat kürzlich
+ /// sein Auth-Token erneuert (= frisch enrolled). Letzteres deckt
+ /// den Fall ab dass nach Re-Enroll noch keine Commands gepusht wurden.
+ var isFresh: Bool {
+ let now = Date()
+ if let ack = lastAckAt, now.timeIntervalSince(ack) < 30 * 60 { return true }
+ if let tok = tokenUpdateAt, now.timeIntervalSince(tok) < 30 * 60 { return true }
+ return false
+ }
+}
+
+enum MDMStatusError: Error, LocalizedError {
+ case sshFailed(String)
+ case parseError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .sshFailed(let msg): return "SSH zu rebreak-mdm fehlgeschlagen: \(msg)"
+ case .parseError(let msg): return "DB-Output-Parse-Error: \(msg)"
+ }
+ }
+}
+
+enum MDMStatus {
+ /// SSH-Host-Alias aus ~/.ssh/config. Vorbedingung: `ssh rebreak-mdm`
+ /// funktioniert ohne Passwort-Prompt.
+ static let sshHost = "rebreak-mdm"
+
+ /// Spawnt `ssh rebreak-mdm 'psql ... -c ""'` und parsed die Antwort.
+ /// Output-Format: tab-separated, eine Zeile pro Row.
+ static func query(udid: String) async throws -> EnrollmentStatus {
+ // ⚠️ udid ist user-controlled (kommt aus libimobiledevice-Output).
+ // Apple-UDIDs sind aber strikt hex+dash → wir validieren defensiv.
+ guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
+ throw MDMStatusError.parseError("UDID hat unerwartetes Format: \(udid)")
+ }
+
+ // Date-Format mit space-separator (kein 'T'), weil 'T' in einfachen
+ // Anführungszeichen über doppelte-Anführungszeichen-shell quoting bricht.
+ let sql = """
+ SELECT \
+ (SELECT count(*) FROM devices WHERE id='\(udid)') AS enrolled, \
+ (SELECT to_char(token_update_at, 'YYYY-MM-DD HH24:MI:SS') FROM devices WHERE id='\(udid)') AS token_update_at, \
+ (SELECT to_char(max(updated_at), 'YYYY-MM-DD HH24:MI:SS') FROM command_results WHERE id='\(udid)') AS last_ack, \
+ (SELECT count(*) FROM enrollment_queue WHERE id='\(udid)' AND active=true) AS pending
+ """
+ let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -F$'\t' -t -c "\#(sql)""#
+
+ let result = try await ProcessRunner.run(
+ "/usr/bin/ssh",
+ arguments: [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ sshHost,
+ remoteCmd,
+ ]
+ )
+ if result.exitCode != 0 {
+ throw MDMStatusError.sshFailed(result.stderr.isEmpty ? result.stdout : result.stderr)
+ }
+
+ let line = result.stdout.split(separator: "\n").first.map(String.init) ?? ""
+ let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ guard fields.count >= 4 else {
+ throw MDMStatusError.parseError("Erwartete 4 Felder, bekam: \(fields.count) — Output: \(result.stdout)")
+ }
+ let isEnrolled = (Int(fields[0]) ?? 0) > 0
+ let tokenUpdateAt = parseTimestamp(fields[1])
+ let lastAckAt = parseTimestamp(fields[2])
+ let pending = Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0
+
+ return EnrollmentStatus(
+ isEnrolled: isEnrolled,
+ tokenUpdateAt: tokenUpdateAt,
+ lastAckAt: lastAckAt,
+ pendingCommandCount: pending
+ )
+ }
+
+ /// Liest das `result`-Field eines spezifischen Commands aus command_results.
+ /// Returnt nil wenn iPhone noch nicht ge-acked hat oder command-uuid nicht
+ /// existiert. Result ist Apple-Plist-XML (response payload des iPhones).
+ static func readCommandResult(udid: String, commandUUID: String) async throws -> String? {
+ guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
+ throw MDMStatusError.parseError("UDID-Format: \(udid)")
+ }
+ guard commandUUID.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else {
+ throw MDMStatusError.parseError("CommandUUID-Format: \(commandUUID)")
+ }
+ let sql = "SELECT result FROM command_results WHERE id='\(udid)' AND command_uuid='\(commandUUID)' AND status='Acknowledged' LIMIT 1"
+ let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -t -c "\#(sql)""#
+ let result = try await ProcessRunner.run(
+ "/usr/bin/ssh",
+ arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", sshHost, remoteCmd]
+ )
+ if result.exitCode != 0 {
+ throw MDMStatusError.sshFailed(result.stderr)
+ }
+ let trimmed = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func parseTimestamp(_ s: String) -> Date? {
+ let trimmed = s.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { return nil }
+ let fb = DateFormatter()
+ // psql to_char(... 'YYYY-MM-DD HH24:MI:SS') liefert "2026-05-27 05:52:57"
+ // (UTC, ohne offset suffix — wir wissen aber dass postgres UTC liefert)
+ fb.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ fb.timeZone = TimeZone(secondsFromGMT: 0)
+ fb.locale = Locale(identifier: "en_US_POSIX")
+ return fb.date(from: trimmed)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Services/Paths.swift b/apps/rebreak-binder-mac/Sources/Services/Paths.swift
new file mode 100644
index 0000000..4ec30ab
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/Paths.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+/// Zentrale Auflösung der system-binaries die der Binder anstößt.
+enum Paths {
+ /// libimobiledevice — meist via Homebrew installiert.
+ static let ideviceinfoCandidates = [
+ "/opt/homebrew/bin/ideviceinfo",
+ "/usr/local/bin/ideviceinfo",
+ ]
+
+ /// Apple Configurator (heißt seit 2026 ohne „2") liefert cfgutil mit.
+ static let cfgutilCandidates = [
+ "/Applications/Apple Configurator.app/Contents/MacOS/cfgutil",
+ "/Applications/Apple Configurator 2.app/Contents/MacOS/cfgutil",
+ ]
+
+ static var cfgutilPath: String? {
+ firstExecutable(in: cfgutilCandidates)
+ }
+
+ /// supervise-magic Go-binary aus dem Monorepo. Pfad relativ zur App-Location:
+ /// app läuft typischerweise aus DerivedData/Build/Products oder ähnlich, daher
+ /// suchen wir mehrere plausible Locations relativ + absolut.
+ static let superviseMagicCandidates: [String] = {
+ let env = ProcessInfo.processInfo.environment["REBREAK_SUPERVISE_MAGIC_BIN"]
+ var candidates: [String] = []
+ if let env, !env.isEmpty { candidates.append(env) }
+ // Repo-Layout: rebreak-monorepo/apps/rebreak-binder-mac/...
+ // supervise-magic liegt in rebreak-monorepo/ops/mdm/supervise-magic/bin/
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
+ candidates.append("\(home)/mono/rebreak-monorepo/ops/mdm/supervise-magic/bin/rebreak-supervise-magic")
+ candidates.append("/usr/local/bin/rebreak-supervise-magic")
+ candidates.append("/opt/homebrew/bin/rebreak-supervise-magic")
+ return candidates
+ }()
+
+ static func firstExecutable(in candidates: [String]) -> String? {
+ candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) })
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Services/ProcessRunner.swift b/apps/rebreak-binder-mac/Sources/Services/ProcessRunner.swift
new file mode 100644
index 0000000..225dfe3
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/ProcessRunner.swift
@@ -0,0 +1,142 @@
+import Foundation
+
+/// Thread-safe String-Accumulator. readabilityHandler werden auf einem
+/// background-thread aufgerufen, daher dürfen captured-vars nicht direkt
+/// mutiert werden (Swift-6-concurrency-rule).
+final class LineBuffer: @unchecked Sendable {
+ private var _value = ""
+ private let lock = NSLock()
+ func append(_ s: String) {
+ lock.lock(); defer { lock.unlock() }
+ _value += s
+ }
+ var value: String {
+ lock.lock(); defer { lock.unlock() }
+ return _value
+ }
+}
+
+enum ProcessRunnerError: Error, LocalizedError {
+ case binaryNotFound(String)
+ case nonZeroExit(Int32, String)
+
+ var errorDescription: String? {
+ switch self {
+ case .binaryNotFound(let path): return "Binary nicht gefunden: \(path)"
+ case .nonZeroExit(let code, let out): return "Exit \(code): \(out)"
+ }
+ }
+}
+
+/// Spawnt einen child-process und sammelt stdout+stderr.
+/// Streamt zeilenweise via AsyncStream wenn `stream: true` — sonst nur Final-Result.
+enum ProcessRunner {
+ struct Result {
+ let exitCode: Int32
+ let stdout: String
+ let stderr: String
+ }
+
+ static func run(
+ _ executable: String,
+ arguments: [String] = [],
+ environment: [String: String]? = nil,
+ currentDirectoryPath: String? = nil
+ ) async throws -> Result {
+ guard FileManager.default.isExecutableFile(atPath: executable) else {
+ throw ProcessRunnerError.binaryNotFound(executable)
+ }
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ if let environment = environment {
+ process.environment = environment
+ }
+ if let cwd = currentDirectoryPath {
+ process.currentDirectoryURL = URL(fileURLWithPath: cwd)
+ }
+
+ let outPipe = Pipe()
+ let errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+
+ return Result(exitCode: process.terminationStatus, stdout: stdout, stderr: stderr)
+ }
+
+ /// Streamt stdout zeilenweise. Caller bekommt jeden Line via onLine-Callback.
+ /// stderr wird parallel gesammelt + im Final-Result returned.
+ @MainActor
+ static func stream(
+ _ executable: String,
+ arguments: [String] = [],
+ environment: [String: String]? = nil,
+ onLine: @escaping (String) -> Void
+ ) async throws -> Result {
+ guard FileManager.default.isExecutableFile(atPath: executable) else {
+ throw ProcessRunnerError.binaryNotFound(executable)
+ }
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ if let environment = environment {
+ process.environment = environment
+ }
+
+ let outPipe = Pipe()
+ let errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+
+ let stdoutBuf = LineBuffer()
+ let stderrBuf = LineBuffer()
+
+ let outHandle = outPipe.fileHandleForReading
+ let errHandle = errPipe.fileHandleForReading
+
+ outHandle.readabilityHandler = { handle in
+ let data = handle.availableData
+ guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return }
+ stdoutBuf.append(chunk)
+ for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) {
+ let s = String(line)
+ if !s.isEmpty {
+ Task { @MainActor in onLine(s) }
+ }
+ }
+ }
+
+ errHandle.readabilityHandler = { handle in
+ let data = handle.availableData
+ guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return }
+ stderrBuf.append(chunk)
+ for line in chunk.split(separator: "\n", omittingEmptySubsequences: false) {
+ let s = String(line)
+ if !s.isEmpty {
+ Task { @MainActor in onLine("[stderr] " + s) }
+ }
+ }
+ }
+
+ try process.run()
+
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ process.terminationHandler = { _ in
+ continuation.resume()
+ }
+ }
+
+ outHandle.readabilityHandler = nil
+ errHandle.readabilityHandler = nil
+
+ return Result(exitCode: process.terminationStatus, stdout: stdoutBuf.value, stderr: stderrBuf.value)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift b/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift
new file mode 100644
index 0000000..ecf3979
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift
@@ -0,0 +1,61 @@
+import Foundation
+
+/// Wrapper um das `rebreak-supervise-magic` Go-binary aus
+/// `ops/mdm/supervise-magic/bin/`. Spawnt es als child-process + streamt
+/// stdout zeilenweise in die UI.
+enum SuperviseRunner {
+ enum RunnerError: Error, LocalizedError {
+ case binaryMissing
+ case nonZeroExit(Int32)
+
+ var errorDescription: String? {
+ switch self {
+ case .binaryMissing:
+ return "supervise-magic Binary nicht gefunden. Bitte aus `ops/mdm/supervise-magic/` via `make build` bauen oder REBREAK_SUPERVISE_MAGIC_BIN setzen."
+ case .nonZeroExit(let code):
+ return "supervise-magic ist mit Exit-Code \(code) abgebrochen."
+ }
+ }
+ }
+
+ /// Pre-Flight Check: liest FMI/SDP-Status + IsSupervised.
+ /// Returnt das geparste output. Mit -v für detaillierte logs.
+ @MainActor
+ static func check(onLine: @escaping (String) -> Void) async throws -> ProcessRunner.Result {
+ guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
+ throw RunnerError.binaryMissing
+ }
+ return try await ProcessRunner.stream(bin, arguments: ["-v", "check"], onLine: onLine)
+ }
+
+ /// Schreibt CloudConfigurationDetails.plist auf das iPhone + reboot.
+ /// supervise-magic macht die ganze MobileBackup2-Sandwich-Logik.
+ @MainActor
+ static func supervise(
+ organizationName: String = "ReBreak",
+ force: Bool = true,
+ onLine: @escaping (String) -> Void
+ ) async throws -> ProcessRunner.Result {
+ guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
+ throw RunnerError.binaryMissing
+ }
+ // -yes ist Pflicht: ohne TTY-Pipe hängt der Bestätigungs-Prompt sonst endlos.
+ var args: [String] = ["-v", "-yes"]
+ if force { args.append("-force") }
+ args.append(contentsOf: ["-org", organizationName, "supervise"])
+ let result = try await ProcessRunner.stream(bin, arguments: args, onLine: onLine)
+ if result.exitCode != 0 {
+ throw RunnerError.nonZeroExit(result.exitCode)
+ }
+ return result
+ }
+
+ /// Reverse-Operation für Tests / Recovery.
+ @MainActor
+ static func unsupervise(onLine: @escaping (String) -> Void) async throws -> ProcessRunner.Result {
+ guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else {
+ throw RunnerError.binaryMissing
+ }
+ return try await ProcessRunner.stream(bin, arguments: ["-v", "-yes", "unsupervise"], onLine: onLine)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift b/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift
new file mode 100644
index 0000000..2ded2f8
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift
@@ -0,0 +1,292 @@
+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
+ }
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/ContentView.swift b/apps/rebreak-binder-mac/Sources/Views/ContentView.swift
new file mode 100644
index 0000000..dd0fc45
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/ContentView.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct ContentView: View {
+ @Environment(WizardModel.self) private var model
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header mit Step-Indicator
+ VStack(spacing: 8) {
+ HStack {
+ Image(systemName: "shield.lefthalf.filled")
+ .foregroundStyle(.tint)
+ Text("ReBreak Binder")
+ .font(.headline)
+ Spacer()
+ if model.step != .done {
+ Text("Schritt \(model.step.stepNumber) von \(WizardStep.total)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+
+ StepIndicator(current: model.step)
+ }
+ .background(Color(NSColor.windowBackgroundColor))
+
+ Divider()
+
+ // Main content
+ Group {
+ switch model.step {
+ case .welcome: WelcomeView()
+ case .preflight: PreflightView()
+ case .supervise: SuperviseView()
+ case .enroll: EnrollView()
+ case .configure: ConfigureView()
+ case .done: DoneView()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/DoneView.swift b/apps/rebreak-binder-mac/Sources/Views/DoneView.swift
new file mode 100644
index 0000000..7f9f9b5
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/DoneView.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+
+struct DoneView: View {
+ @Environment(WizardModel.self) private var model
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Image(systemName: "checkmark.seal.fill")
+ .font(.system(size: 80))
+ .foregroundStyle(.green)
+
+ Text("Schutz aktiv")
+ .font(.largeTitle).bold()
+
+ Text("Dein iPhone ist jetzt an ReBreak gebunden. Casino-Domains werden via NEFilter blockiert — auch wenn du es willst.")
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 40)
+
+ statusSummary
+
+ cooldownNote
+
+ VStack(spacing: 8) {
+ Button("ReBreak öffnen") {
+ if let url = URL(string: "rebreak://") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+
+ Button("Wizard schließen / Neuer Bind") {
+ model.reset()
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(40)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var statusSummary: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ statusRow(label: "Supervised", on: model.device?.isSupervised == true)
+ statusRow(label: "MDM-Enrolled", on: model.device?.isEnrolled == true)
+ statusRow(label: "App managed (nicht löschbar)", on: model.device?.isManaged == true)
+ statusRow(label: "NEFilter aktiv (Casino-Block)", on: model.device?.isFilterActive == true)
+ }
+ .padding()
+ .frame(maxWidth: 400)
+ .background(Color.green.opacity(0.05))
+ .cornerRadius(8)
+ }
+
+ private func statusRow(label: String, on: Bool) -> some View {
+ HStack {
+ Image(systemName: on ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(on ? .green : .gray)
+ Text(label)
+ Spacer()
+ }
+ .font(.callout)
+ }
+
+ private var cooldownNote: some View {
+ HStack(alignment: .top, spacing: 8) {
+ Image(systemName: "clock.badge.exclamationmark")
+ .foregroundStyle(.orange)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("7-Tage-Cooldown").bold()
+ Text("Wenn du den Schutz aufheben willst, gibt's eine 7-Tage-Wartezeit. Das ist Absicht — die Bindung soll deinen impulsiven 'jetzt-doch-zocken'-Moment überdauern.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: 400, alignment: .leading)
+ .background(Color.orange.opacity(0.08))
+ .cornerRadius(6)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift b/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift
new file mode 100644
index 0000000..f3ecd88
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift
@@ -0,0 +1,126 @@
+import SwiftUI
+import AppKit
+
+struct EnrollView: View {
+ @Environment(WizardModel.self) private var model
+
+ @State private var downloadStatus: String?
+ @State private var localPath: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ header
+
+ Text("Jetzt installierst du das **minimale** MDM-Enrollment-Profile, damit dein iPhone mit unserem NanoMDM-Server (mdm.rebreak.org) sprechen kann. Das Profile bringt **keine Restrictions** — nur den MDM-Channel. Restrictions kommen später per Sideload-Lock.")
+ .foregroundStyle(.secondary)
+
+ instructions
+
+ Spacer()
+
+ navigationBar
+ }
+ .padding(40)
+ .onAppear { downloadProfile() }
+ }
+
+ private var header: some View {
+ HStack {
+ Image(systemName: "doc.badge.gearshape")
+ .font(.system(size: 30))
+ .foregroundStyle(.tint)
+ Text("MDM-Enrollment")
+ .font(.title).bold()
+ }
+ }
+
+ private var instructions: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ stepRow(number: 1, text: "Profile wird automatisch vom Server runtergeladen.")
+ if let status = downloadStatus {
+ HStack(spacing: 8) {
+ Image(systemName: localPath != nil ? "checkmark.circle.fill" : "arrow.down.circle")
+ .foregroundStyle(localPath != nil ? .green : .secondary)
+ Text(status).font(.caption).foregroundStyle(.secondary)
+ }
+ .padding(.leading, 36)
+ }
+
+ stepRow(number: 2, text: "Klick „Per AirDrop senden\" → wähle dein iPhone im Sheet.")
+ stepRow(number: 3, text: "Am iPhone: AirDrop-Dialog akzeptieren → Settings öffnet sich automatisch.")
+ stepRow(number: 4, text: "Settings → „Installieren\" tappen → 6-stelligen Geräte-Code eingeben → „Installieren\" bestätigen.")
+ stepRow(number: 5, text: "Zurück hier klick auf „Enrollment fertig → Weiter\".")
+ }
+ }
+
+ private func stepRow(number: Int, text: String) -> some View {
+ HStack(alignment: .top, spacing: 12) {
+ ZStack {
+ Circle().fill(Color.accentColor)
+ Text("\(number)").foregroundStyle(.white).bold()
+ }
+ .frame(width: 24, height: 24)
+ Text(text)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ }
+ }
+
+ private var navigationBar: some View {
+ HStack {
+ Button("Zurück") { model.goTo(.supervise) }
+ .buttonStyle(.bordered)
+ Spacer()
+ if let path = localPath {
+ Button("Per AirDrop senden") {
+ sendViaAirDrop(path: path)
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button("…im Finder zeigen") {
+ NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
+ }
+ .buttonStyle(.bordered)
+ }
+ Button("Enrollment fertig → Weiter") {
+ model.device?.isEnrolled = true
+ model.advance()
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+
+ private func downloadProfile() {
+ let dest = "/tmp/rebreak-enrollment.mobileconfig"
+ downloadStatus = "Lade von mdm.rebreak.org …"
+ Task {
+ do {
+ guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else {
+ throw URLError(.badURL)
+ }
+ let (data, response) = try await URLSession.shared.data(from: url)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ throw URLError(.badServerResponse)
+ }
+ try data.write(to: URL(fileURLWithPath: dest))
+ await MainActor.run {
+ localPath = dest
+ downloadStatus = "Geladen: \(dest) (\(data.count) Bytes)"
+ }
+ } catch {
+ await MainActor.run {
+ downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)"
+ }
+ }
+ }
+ }
+
+ private func sendViaAirDrop(path: String) {
+ let url = URL(fileURLWithPath: path)
+ guard let service = NSSharingService(named: .sendViaAirDrop), service.canPerform(withItems: [url]) else {
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ return
+ }
+ service.perform(withItems: [url])
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/PreflightView.swift b/apps/rebreak-binder-mac/Sources/Views/PreflightView.swift
new file mode 100644
index 0000000..dc06b2f
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/PreflightView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct PreflightView: View {
+ @Environment(WizardModel.self) private var model
+
+ @State private var fmiConfirmed = false
+ @State private var sdpConfirmed = false
+ @State private var appleIdConfirmed = false
+ @State private var rebreakAppInstalled = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ header
+
+ Text("Bevor wir dein iPhone supervisieren, müssen ein paar Apple-Sicherheitschecks erledigt sein. Hak die Punkte ab, sobald du sie auf dem iPhone gemacht hast.")
+ .foregroundStyle(.secondary)
+
+ checklist
+
+ Spacer()
+
+ navigationBar
+ }
+ .padding(40)
+ }
+ }
+
+ private var header: some View {
+ HStack {
+ Image(systemName: "checklist")
+ .font(.system(size: 30))
+ .foregroundStyle(.tint)
+ Text("Pre-Flight Check")
+ .font(.title).bold()
+ }
+ }
+
+ private var checklist: some View {
+ VStack(spacing: 12) {
+ checklistItem(
+ checked: $fmiConfirmed,
+ title: "Find My iPhone deaktiviert",
+ detail: "Settings → [Apple-ID] → Wo ist? → Mein iPhone suchen → AUS. Ohne das blockiert Apple das Supervisieren (ErrorCode 211)."
+ )
+ checklistItem(
+ checked: $sdpConfirmed,
+ title: "Stolen Device Protection ausgeschaltet",
+ detail: "Settings → Face ID & Code → Schutz für gestohlene Geräte → AUS. SDP zwingt FMI an — muss VOR FMI-Toggle aus."
+ )
+ checklistItem(
+ checked: $appleIdConfirmed,
+ title: "Apple-ID-Passwort griffbereit",
+ detail: "Apple fragt evtl. dein Apple-ID-PW während des FMI-Toggles ab. Halte es bereit."
+ )
+ checklistItem(
+ checked: $rebreakAppInstalled,
+ title: "ReBreak-App ist auf dem iPhone installiert",
+ detail: "Über TestFlight (https://testflight.apple.com/join/...). Erst danach kann der Wizard die App in den Managed-State versetzen."
+ )
+ }
+ }
+
+ private func checklistItem(checked: Binding, title: String, detail: String) -> some View {
+ Button(action: { checked.wrappedValue.toggle() }) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: checked.wrappedValue ? "checkmark.square.fill" : "square")
+ .font(.title3)
+ .foregroundStyle(checked.wrappedValue ? Color.accentColor : Color.secondary)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title).font(.headline)
+ Text(detail).font(.callout).foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .padding()
+ .background(Color.gray.opacity(0.05))
+ .cornerRadius(8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var allChecked: Bool {
+ fmiConfirmed && sdpConfirmed && appleIdConfirmed && rebreakAppInstalled
+ }
+
+ private var navigationBar: some View {
+ HStack {
+ Button("Zurück") { model.goTo(.welcome) }
+ .buttonStyle(.bordered)
+ Spacer()
+ Button("Supervisieren starten →") { model.advance() }
+ .buttonStyle(.borderedProminent)
+ .disabled(!allChecked)
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/StepIndicator.swift b/apps/rebreak-binder-mac/Sources/Views/StepIndicator.swift
new file mode 100644
index 0000000..1c67b4a
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/StepIndicator.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct StepIndicator: View {
+ let current: WizardStep
+
+ var body: some View {
+ HStack(spacing: 8) {
+ ForEach(WizardStep.allCases) { step in
+ if step != .done {
+ Circle()
+ .fill(color(for: step))
+ .frame(width: 12, height: 12)
+ if step.rawValue < WizardStep.total - 1 {
+ Rectangle()
+ .fill(Color.gray.opacity(0.3))
+ .frame(height: 1)
+ .frame(maxWidth: 30)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 12)
+ }
+
+ private func color(for step: WizardStep) -> Color {
+ if step.rawValue < current.rawValue { return .green }
+ if step == current { return .accentColor }
+ return Color.gray.opacity(0.3)
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift b/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift
new file mode 100644
index 0000000..a114043
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift
@@ -0,0 +1,131 @@
+import SwiftUI
+
+struct SuperviseView: View {
+ @Environment(WizardModel.self) private var model
+
+ @State private var task: Task?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ header
+
+ Text("Wir schreiben jetzt die Supervision-Plist auf dein iPhone und starten es neu. Das dauert ~60 Sekunden. **Trenne das USB-Kabel nicht.**")
+ .foregroundStyle(.secondary)
+
+ statusBox
+
+ logViewer
+
+ Spacer()
+
+ navigationBar
+ }
+ .padding(40)
+ .onAppear { startIfNeeded() }
+ .onDisappear { task?.cancel() }
+ }
+
+ private var header: some View {
+ HStack {
+ Image(systemName: "lock.shield")
+ .font(.system(size: 30))
+ .foregroundStyle(.tint)
+ Text("Supervisieren")
+ .font(.title).bold()
+ }
+ }
+
+ @ViewBuilder
+ private var statusBox: some View {
+ if model.supervisionRunning {
+ HStack(spacing: 8) {
+ ProgressView().controlSize(.small)
+ Text("supervise-magic läuft …")
+ }
+ .padding(10)
+ .background(Color.blue.opacity(0.08))
+ .cornerRadius(6)
+ } else if let err = model.supervisionError {
+ 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.supervisionLog.isEmpty {
+ HStack {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ Text("Supervisieren abgeschlossen.")
+ }
+ .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.supervisionLog.enumerated()), id: \.offset) { idx, line in
+ Text(line)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(line.contains("[stderr]") ? .orange : .secondary)
+ .id(idx)
+ }
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .background(Color.black.opacity(0.04))
+ .cornerRadius(6)
+ .frame(maxHeight: 220)
+ .onChange(of: model.supervisionLog.count) { _, newCount in
+ if newCount > 0 { proxy.scrollTo(newCount - 1, anchor: .bottom) }
+ }
+ }
+ }
+
+ private var navigationBar: some View {
+ HStack {
+ Button("Zurück") { model.goTo(.preflight) }
+ .buttonStyle(.bordered)
+ .disabled(model.supervisionRunning)
+ Spacer()
+ if model.supervisionError != nil {
+ Button("Neu versuchen") { startSupervise() }
+ .buttonStyle(.bordered)
+ }
+ Button("Weiter →") { model.advance() }
+ .buttonStyle(.borderedProminent)
+ .disabled(model.supervisionRunning || model.supervisionLog.isEmpty || model.supervisionError != nil)
+ }
+ }
+
+ private func startIfNeeded() {
+ if model.supervisionLog.isEmpty && !model.supervisionRunning && model.supervisionError == nil {
+ startSupervise()
+ }
+ }
+
+ private func startSupervise() {
+ model.supervisionLog = []
+ model.supervisionError = nil
+ model.supervisionRunning = true
+ task?.cancel()
+ task = Task { @MainActor in
+ do {
+ _ = try await SuperviseRunner.supervise(organizationName: "ReBreak", force: true) { line in
+ model.supervisionLog.append(line)
+ }
+ model.supervisionRunning = false
+ model.device?.isSupervised = true
+ } catch {
+ model.supervisionError = error.localizedDescription
+ model.supervisionRunning = false
+ }
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift b/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift
new file mode 100644
index 0000000..ca8dcfa
--- /dev/null
+++ b/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift
@@ -0,0 +1,200 @@
+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?
+
+ 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
+ }
+ }
+ }
+ }
+}
diff --git a/apps/rebreak-binder-mac/project.yml b/apps/rebreak-binder-mac/project.yml
new file mode 100644
index 0000000..c602f58
--- /dev/null
+++ b/apps/rebreak-binder-mac/project.yml
@@ -0,0 +1,47 @@
+name: RebreakBinder
+options:
+ bundleIdPrefix: org.rebreak.binder
+ deploymentTarget:
+ macOS: "14.0"
+ createIntermediateGroups: true
+ generateEmptyDirectories: true
+
+settings:
+ base:
+ SWIFT_VERSION: "5.10"
+ MACOSX_DEPLOYMENT_TARGET: "14.0"
+ PRODUCT_BUNDLE_IDENTIFIER: org.rebreak.binder.mac
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: "1"
+ DEVELOPMENT_TEAM: ""
+ CODE_SIGN_STYLE: Automatic
+ CODE_SIGN_IDENTITY: "-"
+ ENABLE_HARDENED_RUNTIME: NO
+ ENABLE_APP_SANDBOX: NO
+
+targets:
+ RebreakBinder:
+ type: application
+ platform: macOS
+ sources:
+ - path: Sources
+ excludes:
+ - "Resources/Info.plist"
+ resources:
+ - path: Sources/Resources/Assets.xcassets
+ optional: true
+ info:
+ path: Sources/Resources/Info.plist
+ properties:
+ CFBundleDisplayName: ReBreak Binder
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
+ LSUIElement: false
+ NSHumanReadableCopyright: "© 2026 Raynis GmbH"
+ NSPrincipalClass: NSApplication
+ NSHighResolutionCapable: true
+ settings:
+ base:
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ COMBINE_HIDPI_IMAGES: YES
diff --git a/ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig b/ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig
new file mode 100644
index 0000000..7a11240
--- /dev/null
+++ b/ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig
@@ -0,0 +1,84 @@
+
+
+
+
+ PayloadType
+ Configuration
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ org.rebreak.protection.contentfilter.mdm
+ PayloadUUID
+ F4A82F1A-9D6E-4B11-A9C2-3B5F8E7C9D11
+ PayloadDisplayName
+ ReBreak Schutz
+ PayloadDescription
+ NEFilter ContentFilter + Anti-Tampering. MDM-managed.
+ PayloadOrganization
+ ReBreak
+ PayloadScope
+ System
+ PayloadContent
+
+
+
+ PayloadType
+ com.apple.webcontent-filter
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ org.rebreak.protection.contentfilter.mdm.filter
+ PayloadUUID
+ D2C71A4F-8B3C-4E5D-9F6A-2B1C8D7E6F50
+ PayloadDisplayName
+ ReBreak Content Filter
+ UserDefinedName
+ ReBreak Schutz
+ FilterType
+ Plugin
+ AutoFilterEnabled
+
+ PluginBundleID
+ org.rebreak.app
+ FilterDataProviderBundleIdentifier
+ org.rebreak.app.ContentFilterExtension
+ FilterDataProviderDesignatedRequirement
+ anchor apple generic and identifier "org.rebreak.app.ContentFilterExtension" and certificate leaf[subject.CN] = "Apple Distribution: CHAHINE BRINI (84BQ7MTFYK)" and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */
+ FilterGrade
+ firewall
+ FilterBrowsers
+
+ FilterSockets
+
+ FilterPackets
+
+ Organization
+ ReBreak
+
+
+
+
+
+ PayloadType
+ com.apple.applicationaccess
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ org.rebreak.protection.contentfilter.mdm.restrictions
+ PayloadUUID
+ A1B2C3D4-E5F6-4789-ABCD-1234567890AB
+ PayloadDisplayName
+ ReBreak Restrictions
+ PayloadDescription
+ Anti-Tampering (Factory-Reset + Profile-Install blockiert)
+ allowEraseContentAndSettings
+
+ allowUIConfigurationProfileInstallation
+
+
+
+
+
diff --git a/ops/mdm/supervise-magic/internal/mobilebackup2/fileserver.go b/ops/mdm/supervise-magic/internal/mobilebackup2/fileserver.go
index feaba1b..f58cd2c 100644
--- a/ops/mdm/supervise-magic/internal/mobilebackup2/fileserver.go
+++ b/ops/mdm/supervise-magic/internal/mobilebackup2/fileserver.go
@@ -57,9 +57,20 @@ func (c *Client) ServeFiles(provider FileProvider, onProgress func(event string,
onProgress = func(string, string) {}
}
+ // Wenn iPhone uns ein ProcessMessage:Response mit ErrorCode=0 schickt,
+ // signalisiert das: Restore-Operation completed successfully. Danach
+ // trennt das iPhone die USB-Verbindung um in den Restore-Mode zu booten →
+ // wir bekommen ein EOF. Das ist KEIN Fehler — es ist erwartetes Verhalten.
+ successConfirmed := false
+
for {
t, args, err := c.dl.Receive()
if err != nil {
+ if successConfirmed {
+ // iPhone hat success-Response geschickt + disconnected um zu rebooten.
+ onProgress("ServeFiles", "device disconnected after successful Response — reboot expected")
+ return nil
+ }
return fmt.Errorf("serve: receive: %w", err)
}
@@ -80,7 +91,25 @@ func (c *Client) ServeFiles(provider FileProvider, onProgress func(event string,
}
// "Response" = iPhone signals operation completed — NICHT antworten,
// nur weiter loopen + auf nächste Message warten.
+ // Wenn ErrorCode=0 → success → nachfolgender EOF ist erwartet.
if msgName == "Response" {
+ if ec, ok := dict["ErrorCode"]; ok {
+ // ErrorCode kann uint64, int64, int etc. sein — alle als „0" prüfen
+ switch v := ec.(type) {
+ case uint64:
+ if v == 0 {
+ successConfirmed = true
+ }
+ case int64:
+ if v == 0 {
+ successConfirmed = true
+ }
+ case int:
+ if v == 0 {
+ successConfirmed = true
+ }
+ }
+ }
continue
}
// Andere Sub-Messages — Status OK respond.