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.