LLM-Prompt (message.post + sos-stream):
- LANG_INSTRUCTIONS Map raus, ersetzt durch dynamische Instruktion
'Reply in {detectedFromUser} ... fallback: {appLang}'
- Lyra matcht jetzt die Sprache der letzten User-Message (per
detectLang Unicode-Detection); App-Locale ist nur noch Fallback
- Instruktion doppelt eingehängt (Anfang + Ende des System-Prompts)
gegen recency bias bei langen deutschen Prompts
TTS (speak dispatcher + speak-cartesia + speak-elevenlabs):
- Kein 'de'-Default mehr für language. detectLang(text, locale) leitet
Sprache primär aus dem Antwort-Text ab (Arabic/Cyrillic/CJK/Turkish-
Letters), Locale als Fallback
- Cartesia + ElevenLabs: language/language_code nur senden wenn
ableitbar, sonst Provider auto-detect statt erzwungenem 'de'
- speak-cartesia: sonic-2 → sonic-3 (Multi-Lang, war beim Dispatcher-
Fix gestern vergessen worden)
- Google: en-US neutraler Fallback statt de-DE-Bias
Neu: server/utils/detect-lang.ts
250 lines
12 KiB
Swift
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
|
|
}
|
|
}
|