chahinebrini 2cb1f8ad6e 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>
2026-05-27 08:37:14 +02:00

230 lines
9.7 KiB
Swift

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