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

161 lines
7.1 KiB
Swift

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