chahinebrini b31066a04c feat(chat): native action sheet + Insta-style heart for DM messages
- ChatBubble: useActionSheet replaces custom Modal (native iOS popup, Android bottom sheet)
- DM mode (isDM prop): hides like-count, shows Insta-style heart badge under bubble when liked
- Group chat unchanged
- Cleanup: remove unused Modal/Platform imports, sheet styles, actionsOpen state
- deploy.sh: auto-detect ANDROID_HOME + auto-create local.properties for local Gradle
- NEXT_RELEASE.md: DM reactions release note
- Includes other staged work across binder-mac, marketing, ops/mdm, ios/
2026-05-30 09:14:32 +02:00

250 lines
12 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 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: "<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)
}
}
/// 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
}
}