feat(binder-mac): SwiftUI Wizard für Self-Bind End-to-End-Flow

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-27 08:37:14 +02:00
parent 01374c426e
commit 2cb1f8ad6e
27 changed files with 2256 additions and 0 deletions

15
apps/rebreak-binder-mac/.gitignore vendored Normal file
View File

@ -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

View File

@ -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`)

View File

@ -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",
]
}

View File

@ -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
}
}

View File

@ -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 }
}

View File

@ -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)
}
}

View File

@ -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}
}

View File

@ -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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ReBreak Binder</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<false/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Raynis GmbH</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

View File

@ -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: "<identifier> <version> <displayName>".
/// 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)
}
}
}

View File

@ -0,0 +1,229 @@
import Foundation
/// HTTP-Client für NanoMDM (https://mdm.rebreak.org).
/// Schickt Commands via POST /v1/enqueue/<udid> 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/<udid>?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("<string>managed</string>")
}
/// 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 `<data>...</data>` (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
}
}

View File

@ -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/<udid>` 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 "<query>"'` 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)
}
}

View File

@ -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) })
}
}

View File

@ -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<Void, Never>) in
process.terminationHandler = { _ in
continuation.resume()
}
}
outHandle.readabilityHandler = nil
errHandle.readabilityHandler = nil
return Result(exitCode: process.terminationStatus, stdout: stdoutBuf.value, stderr: stderrBuf.value)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,292 @@
import SwiftUI
struct ConfigureView: View {
@Environment(WizardModel.self) private var model
@State private var task: Task<Void, Never>?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
header
Text("Wizard pusht 2 MDM-Commands (silent über APNs): App wird **managed**, NEFilter-Mode aktiviert. Danach Sideload des Lock-Profils per AirDrop (User-Tap am iPhone).")
.foregroundStyle(.secondary)
stepList
appPreStatus
statusBox
logViewer
Spacer()
navigationBar
}
.padding(40)
.onAppear { startIfNeeded() }
.onDisappear { task?.cancel() }
}
private var header: some View {
HStack {
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 30))
.foregroundStyle(.tint)
Text("Schutz aktivieren")
.font(.title).bold()
}
}
private var stepList: some View {
VStack(alignment: .leading, spacing: 6) {
Label("Pre-Check: ist ReBreak-App auf iPhone? Managed?", systemImage: "magnifyingglass")
Label("Mode-Auswahl: Take-Management (TF-installiert) ODER Install-Push (Ad-Hoc-IPA via Manifest)", systemImage: "arrow.triangle.branch")
Label("Settings mdmSupervised=true (NEFilter-Mode)", systemImage: "shield")
Label("Post-Check: ManagedApplicationList Query — managed verified?", systemImage: "checkmark.seal")
Label("Sideload Lock-Profile per AirDrop", systemImage: "paperplane")
}
.font(.callout)
.foregroundStyle(.secondary)
}
/// Pre-Check Status der ReBreak-App auf dem iPhone.
private var appPreStatus: some View {
let installed = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true
return HStack(spacing: 8) {
Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(installed ? .green : .orange)
Text(installed
? "ReBreak-App ist installiert (cfgutil) — Mode: Take-Management"
: "ReBreak-App NICHT installiert — Mode: Install-Push (Manifest)")
.font(.callout)
}
.padding(8)
.background((installed ? Color.green : Color.orange).opacity(0.08))
.cornerRadius(6)
}
@ViewBuilder
private var statusBox: some View {
if model.configureRunning {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Sende Commands an NanoMDM …")
}
.padding(10)
.background(Color.blue.opacity(0.08))
.cornerRadius(6)
} else if let err = model.configureError {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "xmark.octagon").foregroundStyle(.red)
Text(err).font(.callout)
}
.padding(10)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
} else if !model.configureLog.isEmpty {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text("3 Commands erfolgreich enqueued. iPhone empfängt via APNs (~530 Sekunden).")
}
.padding(10)
.background(Color.green.opacity(0.08))
.cornerRadius(6)
}
}
private var logViewer: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(Array(model.configureLog.enumerated()), id: \.offset) { idx, line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(line.hasPrefix("") ? .red : .secondary)
.id(idx)
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.black.opacity(0.04))
.cornerRadius(6)
.frame(maxHeight: 200)
.onChange(of: model.configureLog.count) { _, newCount in
if newCount > 0 { proxy.scrollTo(newCount - 1, anchor: .bottom) }
}
}
}
private var navigationBar: some View {
HStack {
Button("Zurück") { model.goTo(.enroll) }
.buttonStyle(.bordered)
.disabled(model.configureRunning)
Spacer()
if let path = sideloadProfilePath, !model.configureRunning, model.configureError == nil, !model.configureLog.isEmpty {
Button("Lock-Profile per AirDrop senden") {
sendViaAirDrop(path: path)
}
.buttonStyle(.borderedProminent)
Button("…im Finder zeigen") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
.buttonStyle(.bordered)
}
if model.configureError != nil {
Button("Neu versuchen") { startConfigure() }
.buttonStyle(.bordered)
}
Button("Schutz ist aktiv → Fertig") { model.advance() }
.buttonStyle(.borderedProminent)
.disabled(model.configureRunning || model.configureLog.isEmpty || model.configureError != nil)
}
}
/// Pfad zur Sideload-Profile-Datei (nicht zur MDM-Push-Variante).
/// User dropt die per AirDrop an's iPhone.
private var sideloadProfilePath: String? {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let candidates = [
"\(home)/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig",
]
return candidates.first(where: { FileManager.default.fileExists(atPath: $0) })
}
/// Öffnet macOS' NSSharingServicePicker mit AirDrop-Service vorausgewählt.
/// User klickt das eigene iPhone an File wird übertragen iPhone fragt nach Install.
private func sendViaAirDrop(path: String) {
let url = URL(fileURLWithPath: path)
guard let service = NSSharingService(named: .sendViaAirDrop) else {
model.configureLog.append("⚠ AirDrop-Service nicht verfügbar — manuell per Finder teilen.")
return
}
if service.canPerform(withItems: [url]) {
service.perform(withItems: [url])
model.configureLog.append("→ AirDrop-Sheet geöffnet — wähle dein iPhone aus.")
} else {
model.configureLog.append("⚠ AirDrop kann diese Datei nicht senden — manuell per Finder.")
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
private func startIfNeeded() {
if model.configureLog.isEmpty && !model.configureRunning && model.configureError == nil {
startConfigure()
}
}
private func startConfigure() {
guard let udid = model.device?.udid else {
model.configureError = "Kein Device — bitte zurück zu Step 1."
return
}
model.configureLog = []
model.configureError = nil
model.configureRunning = true
task?.cancel()
task = Task { @MainActor in
do {
// PRE-FLIGHT: real-check ob iPhone überhaupt enrolled ist + check-in macht
model.configureLog.append("→ Pre-Flight: NanoMDM-Enrollment-Status …")
let status = try await MDMStatus.query(udid: udid)
if !status.isEnrolled {
throw NSError(domain: "Binder", code: 1, userInfo: [NSLocalizedDescriptionKey:
"iPhone ist NICHT in NanoMDM enrolled. Bitte Step 4 (Enroll) wiederholen."])
}
let pending = status.pendingCommandCount
if let ack = status.lastAckAt {
let ageMin = Int(Date().timeIntervalSince(ack) / 60)
model.configureLog.append("✓ enrolled · letzter Ack vor \(ageMin) min · \(pending) pending")
if ageMin > 30 {
model.configureLog.append("⚠ Letzter Check-In ist alt (>30min). iPhone reagiert evtl. nicht.")
model.configureLog.append("⚠ Falls Commands nach 1min nicht ausgeführt: Step 4 (Enroll) wiederholen.")
}
} else {
model.configureLog.append("⚠ Noch nie ge-acked. Enrollment vermutlich tot — Step 4 wiederholen.")
}
model.configureLog.append("→ Ping NanoMDM …")
let version = try await MDMClient.ping()
model.configureLog.append("✓ NanoMDM \(version.trimmingCharacters(in: .whitespacesAndNewlines))")
// Marker für Post-Flight: alle Acks NACH diesem Zeitstempel
// gehören zu unseren Commands. NanoMDM's enrollment_queue.active
// bleibt nach Ack auf true daher zählen wir command_results.
let pushStartTime = Date()
// Mode-Auswahl: wenn App schon installed Take-Management,
// sonst Install-Push via Manifest.
let appAlreadyInstalled = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true
let modeLabel = appAlreadyInstalled
? "Take-Management (App schon installiert, nur managed-state setzen)"
: "Install-Push via Manifest (App nicht installiert, Ad-Hoc-IPA pushen)"
model.configureLog.append("→ Mode: \(modeLabel)")
model.configureLog.append("→ [1/2] MDM-Push InstallApplication …")
let r1: String
if appAlreadyInstalled {
r1 = try await MDMClient.takeManagement(udid: udid)
} else {
r1 = try await MDMClient.installApp(udid: udid)
}
model.configureLog.append("✓ enqueued: \(r1.prefix(80))")
model.configureLog.append("→ [2/2] MDM-Push Settings mdmSupervised=true …")
let r2 = try await MDMClient.setSupervisedMode(udid: udid)
model.configureLog.append("✓ enqueued: \(r2.prefix(80))")
model.configureLog.append("")
model.configureLog.append("Beide MDM-Pushes enqueued. Warte 30s und re-check ob iPhone sie acked …")
// POST-FLIGHT: 30s warten + checken ob neue Acks NACH pushStartTime da sind
try? await Task.sleep(for: .seconds(30))
let after = try await MDMStatus.query(udid: udid)
let lastAckAfter = after.lastAckAt
let hasNewAck = (lastAckAfter ?? .distantPast) > pushStartTime
if hasNewAck {
model.configureLog.append("✓ iPhone hat ge-acked (\(lastAckAfter!.formatted(date: .omitted, time: .standard))).")
// Post-Check 1: cfgutil refresh ist App jetzt installiert?
let appsAfter = await DeviceDetector.installedAppBundleIDs()
let isAppInstalled = appsAfter.contains("org.rebreak.app")
model.configureLog.append(isAppInstalled
? "✓ ReBreak-App jetzt auf iPhone (cfgutil)."
: "⚠ ReBreak-App noch nicht auf iPhone — iPhone lädt evtl. noch (IPA = 19.6MB).")
// Post-Check 2: ManagedApplicationList-Query ist App managed?
model.configureLog.append("→ Post-Check: ManagedApplicationList query …")
do {
if let isManaged = try await MDMClient.checkAppIsManaged(udid: udid) {
model.configureLog.append(isManaged
? "✓ ReBreak ist MANAGED. App nicht löschbar durch User."
: "⚠ ReBreak ist installiert aber NICHT managed.")
model.device?.isManaged = isManaged
} else {
model.configureLog.append("⚠ iPhone hat Managed-Query nicht (rechtzeitig) ge-acked.")
}
} catch {
model.configureLog.append("⚠ Post-Check fehlgeschlagen: \(error.localizedDescription)")
}
} else {
model.configureLog.append("✗ Kein neuer Ack nach 30s. Push-Zeitstempel: \(pushStartTime.formatted(date: .omitted, time: .standard)), letzter Ack: \(lastAckAfter?.formatted(date: .omitted, time: .standard) ?? "nie").")
throw NSError(domain: "Binder", code: 2, userInfo: [NSLocalizedDescriptionKey:
"iPhone hat 30s lang keine MDM-Commands abgeholt — MDM-Channel tot. Step 4 wiederholen."])
}
model.configureLog.append("")
model.configureLog.append("→ Sideload-Step: Lock-Profile per AirDrop ans iPhone schicken …")
model.configureLog.append(" Datei: \(sideloadProfilePath ?? "(nicht gefunden)")")
model.configureLog.append(" Am iPhone: Profil-Dialog akzeptieren → Settings → Profil installieren")
model.device?.isFilterActive = true // wird's nach sideload sein
model.configureRunning = false
} catch {
model.configureLog.append("✗ Fehler: \(error.localizedDescription)")
model.configureError = error.localizedDescription
model.configureRunning = false
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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])
}
}

View File

@ -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<Bool>, 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)
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,131 @@
import SwiftUI
struct SuperviseView: 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("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
}
}
}
}

View File

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

View File

@ -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

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.contentfilter.mdm</string>
<key>PayloadUUID</key>
<string>F4A82F1A-9D6E-4B11-A9C2-3B5F8E7C9D11</string>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz</string>
<key>PayloadDescription</key>
<string>NEFilter ContentFilter + Anti-Tampering. MDM-managed.</string>
<key>PayloadOrganization</key>
<string>ReBreak</string>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadContent</key>
<array>
<!-- 1) WEBCONTENT-FILTER (NEFilter via ReBreak-App-Extension) -->
<dict>
<key>PayloadType</key>
<string>com.apple.webcontent-filter</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.contentfilter.mdm.filter</string>
<key>PayloadUUID</key>
<string>D2C71A4F-8B3C-4E5D-9F6A-2B1C8D7E6F50</string>
<key>PayloadDisplayName</key>
<string>ReBreak Content Filter</string>
<key>UserDefinedName</key>
<string>ReBreak Schutz</string>
<key>FilterType</key>
<string>Plugin</string>
<key>AutoFilterEnabled</key>
<true/>
<key>PluginBundleID</key>
<string>org.rebreak.app</string>
<key>FilterDataProviderBundleIdentifier</key>
<string>org.rebreak.app.ContentFilterExtension</string>
<key>FilterDataProviderDesignatedRequirement</key>
<string>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 */</string>
<key>FilterGrade</key>
<string>firewall</string>
<key>FilterBrowsers</key>
<true/>
<key>FilterSockets</key>
<false/>
<key>FilterPackets</key>
<false/>
<key>Organization</key>
<string>ReBreak</string>
</dict>
<!-- 2) RESTRICTIONS (supervised-only) -->
<!-- HINWEIS: allowAppRemoval=false ist bewusst NICHT drin (global zu invasiv).
Per-App-Removal-Block für ReBreak kommt automatisch über Take-Management
(Settings → InstallApplication ChangeManagementState=Managed → iOS verbirgt
Wackel-X für managed apps). Andere Apps bleiben löschbar. -->
<dict>
<key>PayloadType</key>
<string>com.apple.applicationaccess</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.contentfilter.mdm.restrictions</string>
<key>PayloadUUID</key>
<string>A1B2C3D4-E5F6-4789-ABCD-1234567890AB</string>
<key>PayloadDisplayName</key>
<string>ReBreak Restrictions</string>
<key>PayloadDescription</key>
<string>Anti-Tampering (Factory-Reset + Profile-Install blockiert)</string>
<key>allowEraseContentAndSettings</key>
<false/>
<key>allowUIConfigurationProfileInstallation</key>
<false/>
</dict>
</array>
</dict>
</plist>

View File

@ -57,9 +57,20 @@ func (c *Client) ServeFiles(provider FileProvider, onProgress func(event string,
onProgress = func(string, 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 { for {
t, args, err := c.dl.Receive() t, args, err := c.dl.Receive()
if err != nil { 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) 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, // "Response" = iPhone signals operation completed — NICHT antworten,
// nur weiter loopen + auf nächste Message warten. // nur weiter loopen + auf nächste Message warten.
// Wenn ErrorCode=0 → success → nachfolgender EOF ist erwartet.
if msgName == "Response" { 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 continue
} }
// Andere Sub-Messages — Status OK respond. // Andere Sub-Messages — Status OK respond.