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 cfgutilMissing case noDevice case deviceLocked case profileUserInteractionRequired case profileInstallRequiresManagementTool case parseError(String) var errorDescription: String? { switch self { case .ideviceinfoMissing: return "ideviceinfo nicht gefunden — bitte `brew install libimobiledevice` ausführen." case .cfgutilMissing: return "cfgutil nicht gefunden — bitte Apple Configurator installieren." case .noDevice: return "Kein iPhone via USB erkannt. Kabel + Trust-Dialog am iPhone prüfen." case .deviceLocked: return "iPhone ist gesperrt. Bitte entsperren und USB verbunden lassen." case .profileUserInteractionRequired: return "iOS verlangt eine Bestätigung direkt am iPhone, um das Profil zu installieren." case .profileInstallRequiresManagementTool: return "Lokale Profil-Installation ist durch iOS-Policy blockiert (DMC 4020). Dieses Profil muss per MDM-Command installiert werden oder per AirDrop/User-Flow bestätigt werden." 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 && (normalizedOrganizationName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) } var normalizedOrganizationName: String? { organizationName? .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } } /// 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 = normalizeOrgName(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") } if status.organizationName == nil, let v = parseColon(line: line, key: "OrganizationName") ?? parseColon(line: line, key: "SupervisionOrganizationName") { status.organizationName = normalizeOrgName(v) } } } return status } private static func normalizeOrgName(_ value: String) -> String { value .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } /// 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) } } /// Versucht ein .mobileconfig direkt auf ein per USB verbundenes iPhone zu /// installieren. Nutzt cfgutil und ist damit ohne AirDrop-Dialog möglich, /// sofern Device trusted/entsperrt ist. static func installProfileSilently(path: String) async throws { guard let cfgutil = Paths.cfgutilPath else { throw DetectorError.cfgutilMissing } let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "install-profile", path]) if r.exitCode != 0 { let err = r.stderr.isEmpty ? r.stdout : r.stderr if err.localizedCaseInsensitiveContains("device is locked") { throw DetectorError.deviceLocked } if err.localizedCaseInsensitiveContains("benutzerinteraktion") || err.localizedCaseInsensitiveContains("user interaction") || err.contains("MCInstallationErrorDomain Code: 4009") { throw DetectorError.profileUserInteractionRequired } if err.contains("DMCInstallationErrorDomain") && err.contains("Code: 4020") { throw DetectorError.profileInstallRequiresManagementTool } throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) } } /// Entfernt eine App per Bundle-ID via cfgutil (USB). static func removeApp(bundleID: String) async throws { guard let cfgutil = Paths.cfgutilPath else { throw DetectorError.cfgutilMissing } let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "remove-app", bundleID]) if r.exitCode != 0 { let err = r.stderr.isEmpty ? r.stdout : r.stderr throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) } } /// Entfernt alle per Identifier angegebenen Profile via cfgutil. /// Wird für interne Test-Resets genutzt. static func removeProfiles(identifiers: [String]) async throws { guard let cfgutil = Paths.cfgutilPath else { throw DetectorError.cfgutilMissing } for identifier in identifiers { let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "remove-profile", identifier]) if r.exitCode != 0 { let err = r.stderr.isEmpty ? r.stdout : r.stderr throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) } } } /// Internal QA helper: entfernt alle Profile mit `org.rebreak.` Prefix. /// Returnt die tatsächlich angezielten Profil-IDs. static func removeAllReBreakProfiles() async throws -> [String] { let profileIDs = await installedProfileIDs().filter { $0.hasPrefix("org.rebreak.") } guard !profileIDs.isEmpty else { return [] } try await removeProfiles(identifiers: profileIDs) return profileIDs } }