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>
161 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|