chahinebrini 685782b538 fix(coach): dynamische Sprache (Text-Detection + App-Locale-Fallback)
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
2026-05-31 00:12:40 +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
}
}