import Foundation /// Wrapper um `ideviceinfo` aus libimobiledevice. /// Liest die wichtigsten Lockdown-Keys eines per USB verbundenen iPhones. enum DeviceDetector { enum DetectorError: Error, LocalizedError { case ideviceinfoMissing case noDevice case parseError(String) var errorDescription: String? { switch self { case .ideviceinfoMissing: return "ideviceinfo nicht gefunden — bitte `brew install libimobiledevice` ausführen." case .noDevice: return "Kein iPhone via USB erkannt. Kabel + Trust-Dialog am iPhone prüfen." case .parseError(let msg): return "Parse-Fehler: \(msg)" } } } static func detect() async throws -> DeviceState { guard let bin = Paths.firstExecutable(in: Paths.ideviceinfoCandidates) else { throw DetectorError.ideviceinfoMissing } async let udid = readKey(bin: bin, key: "UniqueDeviceID") async let model = readKey(bin: bin, key: "ProductType") async let version = readKey(bin: bin, key: "ProductVersion") async let name = readKey(bin: bin, key: "DeviceName") let (u, m, v, n) = try await (udid, model, version, name) guard !u.isEmpty else { throw DetectorError.noDevice } return DeviceState( udid: u, productType: m, productVersion: v, deviceName: n.isEmpty ? "iPhone" : n ) } private static func readKey(bin: String, key: String) async throws -> String { let r = try await ProcessRunner.run(bin, arguments: ["-k", key]) if r.exitCode != 0 { // ideviceinfo gibt non-zero zurück wenn kein Device da ist if r.stderr.contains("No device found") || r.stderr.contains("ERROR") { throw DetectorError.noDevice } throw DetectorError.parseError(r.stderr) } return r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) } // MARK: - Supervision status via supervise-magic struct SupervisionStatus: Equatable { var isSupervised: Bool var organizationName: String? var findMyEnabled: Bool? /// Wir betrachten das Gerät als "schon durch uns gebunden" wenn /// OrganizationName matched. Case-insensitive für Robustheit. var isOwnedByReBreak: Bool { isSupervised && (organizationName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) } } /// Liest IsSupervised + OrganizationName via `supervise-magic cloud-config`. /// Falls iPhone nicht ansprechbar oder unsupervised → returnt nil-felder. static func readSupervisionStatus() async -> SupervisionStatus { guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else { return SupervisionStatus(isSupervised: false, organizationName: nil, findMyEnabled: nil) } var status = SupervisionStatus(isSupervised: false, organizationName: nil, findMyEnabled: nil) // 1) cloud-config liest IsSupervised + OrganizationName direkt aus MCInstall. if let r = try? await ProcessRunner.run(bin, arguments: ["cloud-config"]), r.exitCode == 0 { for raw in r.stdout.split(separator: "\n") { let line = String(raw) if let v = parseEquals(line: line, key: "IsSupervised") { status.isSupervised = (v.lowercased() == "true") } if let v = parseEquals(line: line, key: "OrganizationName") { status.organizationName = v } } } // 2) `check` gibt zusätzlich FindMyEnabled. if let r = try? await ProcessRunner.run(bin, arguments: ["check"]), !r.stdout.isEmpty { for raw in r.stdout.split(separator: "\n") { let line = String(raw) if let v = parseColon(line: line, key: "FindMyEnabled") { status.findMyEnabled = (v.lowercased() == "true") } if status.isSupervised == false, let v = parseColon(line: line, key: "IsSupervised") { status.isSupervised = (v.lowercased() == "true") } } } return status } /// Parse ` Key = Value` (cloud-config Format). private static func parseEquals(line: String, key: String) -> String? { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix(key) else { return nil } let parts = trimmed.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } guard parts.count == 2, parts[0] == key else { return nil } return parts[1] } /// Parse ` Key: Value` (check Format). private static func parseColon(line: String, key: String) -> String? { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix(key + ":") else { return nil } let after = trimmed.dropFirst(key.count + 1) return after.trimmingCharacters(in: .whitespaces) } // MARK: - Installed configuration profiles via cfgutil /// Listet alle PayloadIdentifiers der aktuell auf dem iPhone installierten /// Configuration-Profiles. cfgutil-output-Format: " ". /// Returnt leeres Array wenn cfgutil fehlt oder Device nicht ansprechbar. static func installedProfileIDs() async -> [String] { guard let cfgutil = Paths.cfgutilPath else { return [] } guard let r = try? await ProcessRunner.run(cfgutil, arguments: ["--foreach", "get", "configurationProfiles"]), r.exitCode == 0 else { return [] } // Format: "org.rebreak.mdm.enrollment ReBreak MDM v1" (1 line per profile) return r.stdout .split(separator: "\n") .compactMap { line -> String? in let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } // erstes Token = identifier return trimmed.split(separator: " ", maxSplits: 1).first.map(String.init) } } /// Listet alle installierten App-Bundle-IDs. /// Format: "com.bundle.id\tDisplayName (CFBundleName) vBuildNumber". /// Hinweis: cfgutil hat keinen direkten "managed?"-Indikator pro App — /// managed-status muss via MDM-`ManagedApplicationList`-Command geprüft werden. static func installedAppBundleIDs() async -> [String] { guard let cfgutil = Paths.cfgutilPath else { return [] } guard let r = try? await ProcessRunner.run(cfgutil, arguments: ["--foreach", "get", "installedApps"]), r.exitCode == 0 else { return [] } return r.stdout .split(separator: "\n") .compactMap { line -> String? in let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } // erstes Token (vor TAB oder Space) = bundle-id return trimmed.split(whereSeparator: { $0 == "\t" || $0 == " " }).first.map(String.init) } } }