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:
parent
01374c426e
commit
2cb1f8ad6e
15
apps/rebreak-binder-mac/.gitignore
vendored
Normal file
15
apps/rebreak-binder-mac/.gitignore
vendored
Normal 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
|
||||||
92
apps/rebreak-binder-mac/README.md
Normal file
92
apps/rebreak-binder-mac/README.md
Normal 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`)
|
||||||
60
apps/rebreak-binder-mac/Sources/Models/DeviceState.swift
Normal file
60
apps/rebreak-binder-mac/Sources/Models/DeviceState.swift
Normal 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",
|
||||||
|
]
|
||||||
|
}
|
||||||
45
apps/rebreak-binder-mac/Sources/Models/WizardModel.swift
Normal file
45
apps/rebreak-binder-mac/Sources/Models/WizardModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/rebreak-binder-mac/Sources/Models/WizardStep.swift
Normal file
27
apps/rebreak-binder-mac/Sources/Models/WizardStep.swift
Normal 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 }
|
||||||
|
}
|
||||||
16
apps/rebreak-binder-mac/Sources/RebreakBinderApp.swift
Normal file
16
apps/rebreak-binder-mac/Sources/RebreakBinderApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/rebreak-binder-mac/Sources/Resources/Info.plist
Normal file
34
apps/rebreak-binder-mac/Sources/Resources/Info.plist
Normal 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>
|
||||||
160
apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift
Normal file
160
apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
apps/rebreak-binder-mac/Sources/Services/MDMClient.swift
Normal file
229
apps/rebreak-binder-mac/Sources/Services/MDMClient.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
134
apps/rebreak-binder-mac/Sources/Services/MDMStatus.swift
Normal file
134
apps/rebreak-binder-mac/Sources/Services/MDMStatus.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/rebreak-binder-mac/Sources/Services/Paths.swift
Normal file
40
apps/rebreak-binder-mac/Sources/Services/Paths.swift
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apps/rebreak-binder-mac/Sources/Services/ProcessRunner.swift
Normal file
142
apps/rebreak-binder-mac/Sources/Services/ProcessRunner.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
292
apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift
Normal file
292
apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift
Normal 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 (~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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/rebreak-binder-mac/Sources/Views/ContentView.swift
Normal file
45
apps/rebreak-binder-mac/Sources/Views/ContentView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
apps/rebreak-binder-mac/Sources/Views/DoneView.swift
Normal file
83
apps/rebreak-binder-mac/Sources/Views/DoneView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/rebreak-binder-mac/Sources/Views/EnrollView.swift
Normal file
126
apps/rebreak-binder-mac/Sources/Views/EnrollView.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/rebreak-binder-mac/Sources/Views/PreflightView.swift
Normal file
98
apps/rebreak-binder-mac/Sources/Views/PreflightView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/rebreak-binder-mac/Sources/Views/StepIndicator.swift
Normal file
30
apps/rebreak-binder-mac/Sources/Views/StepIndicator.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift
Normal file
131
apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift
Normal file
200
apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/rebreak-binder-mac/project.yml
Normal file
47
apps/rebreak-binder-mac/project.yml
Normal 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
|
||||||
84
ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig
Normal file
84
ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig
Normal 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>
|
||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user