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

337 lines
12 KiB
Swift

import SwiftUI
import AppKit
struct TransferAnimationView: View {
let leftSymbol: String
let rightSymbol: String
let title: String
let subtitle: String
let isActive: Bool
let isDone: Bool
@State private var animate = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.callout.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 14) {
iconNode(systemName: leftSymbol)
ZStack(alignment: .leading) {
Capsule()
.fill(Color.gray.opacity(0.22))
.frame(height: 6)
if isDone {
Capsule()
.fill(Color.green)
.frame(height: 6)
} else if isActive {
Circle()
.fill(Color.accentColor)
.frame(width: 12, height: 12)
.offset(x: animate ? 150 : 0)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: false), value: animate)
}
}
.frame(width: 150)
iconNode(systemName: rightSymbol)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.06))
.cornerRadius(10)
.onAppear {
animate = isActive
}
.onChange(of: isActive) { _, active in
animate = active
}
}
private func iconNode(systemName: String) -> some View {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: 34, height: 34)
Image(systemName: systemName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.tint)
}
}
}
struct EnrollView: View {
@Environment(WizardModel.self) private var model
@State private var downloadStatus: String?
@State private var localPath: String?
@State private var flowStatus: String?
@State private var busy = false
@State private var enrollmentReady = false
@State private var pollTask: Task<Void, Never>?
@State private var didAutoAdvance = false
@State private var showUnlockModal = false
private let enrollmentProfileID = "org.rebreak.mdm.enrollment"
var body: some View {
VStack(alignment: .leading, spacing: 20) {
header
Text("Wir installieren jetzt automatisch das Verbindungs-Profil für die Geräteverwaltung. Danach prüfen wir selbst, ob alles korrekt aktiv ist.")
.foregroundStyle(.secondary)
TransferAnimationView(
leftSymbol: "iphone.gen3",
rightSymbol: "server.rack",
title: "Enrollment Live-Status",
subtitle: enrollmentReady
? "Profil aktiv und iPhone am ReBreak-Server bestätigt enrolled."
: "Warte auf Profil-Installation am iPhone und Backend-Enrollment.",
isActive: busy && !enrollmentReady,
isDone: enrollmentReady
)
instructions
Spacer()
navigationBar
}
.padding(40)
.onAppear { startIfNeeded() }
.onDisappear { pollTask?.cancel() }
.alert("iPhone entsperren", isPresented: $showUnlockModal) {
Button("Erneut versuchen") {
if let path = localPath {
busy = true
runInstallFlow(path: path)
}
}
Button("OK", role: .cancel) {}
} message: {
Text("Bitte iPhone entsperren und verbunden lassen. Danach erneut versuchen.")
}
}
private var header: some View {
HStack {
Image(systemName: "doc.badge.gearshape")
.font(.system(size: 30))
.foregroundStyle(.tint)
Text("Verbindung einrichten")
.font(.title).bold()
}
}
private var instructions: some View {
VStack(alignment: .leading, spacing: 14) {
stepRow(number: 1, text: "Profil wird automatisch geladen.")
if let status = downloadStatus {
HStack(spacing: 8) {
Image(systemName: localPath != nil ? "checkmark.circle.fill" : "arrow.down.circle")
.foregroundStyle(localPath != nil ? .green : .secondary)
Text(status).font(.caption).foregroundStyle(.secondary)
}
.padding(.leading, 36)
}
stepRow(number: 2, text: "Automatische Installation wird versucht.")
if let status = flowStatus {
HStack(spacing: 8) {
Image(systemName: enrollmentReady ? "checkmark.circle.fill" : (busy ? "hourglass" : "info.circle"))
.foregroundStyle(enrollmentReady ? .green : .secondary)
Text(status).font(.caption).foregroundStyle(.secondary)
}
.padding(.leading, 36)
}
stepRow(number: 3, text: "Wenn iOS den Profil-Dialog zeigt, bitte direkt am iPhone bestätigen.")
stepRow(number: 4, text: "Wir warten automatisch auf Profil aktiv + Backend-Enroll und schalten dann Weiter frei.")
}
}
private func stepRow(number: Int, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
Circle().fill(Color.accentColor)
Text("\(number)").foregroundStyle(.white).bold()
}
.frame(width: 24, height: 24)
Text(text)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
}
private var navigationBar: some View {
HStack {
Button("Zurück") { model.goTo(.supervise) }
.buttonStyle(.bordered)
Spacer()
if enrollmentReady {
Text("Enrollment bestätigt. Weiterleitung läuft automatisch …")
.font(.callout)
.foregroundStyle(.secondary)
} else {
Text("Bitte kurz warten …")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private func startIfNeeded() {
if localPath == nil && !busy && !enrollmentReady {
downloadProfile()
}
}
private func downloadProfile() {
let dest = "/tmp/rebreak-enrollment.mobileconfig"
busy = true
downloadStatus = "Lade von mdm.rebreak.org …"
Task {
do {
guard let url = URL(string: "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig") else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
try data.write(to: URL(fileURLWithPath: dest))
await MainActor.run {
localPath = dest
downloadStatus = "Geladen: \(dest) (\(data.count) Bytes)"
}
await MainActor.run {
runInstallFlow(path: dest)
}
} catch {
await MainActor.run {
busy = false
downloadStatus = "Download fehlgeschlagen: \(error.localizedDescription)"
}
}
}
}
private func runInstallFlow(path: String) {
guard !enrollmentReady else { return }
flowStatus = "Versuche automatische Installation via USB …"
Task {
var installSucceeded = false
var shouldPollEnrollment = false
for attempt in 1...3 {
do {
try await DeviceDetector.installProfileSilently(path: path)
installSucceeded = true
shouldPollEnrollment = true
await MainActor.run {
flowStatus = "✓ Profil wurde übertragen. Prüfe Installation + Enrollment …"
}
break
} catch DeviceDetector.DetectorError.deviceLocked {
if attempt < 3 {
await MainActor.run {
flowStatus = "iPhone ist gesperrt. Retry \(attempt)/2 … bitte entsperren."
}
try? await Task.sleep(for: .seconds(3))
continue
}
await MainActor.run {
busy = false
flowStatus = "iPhone weiter gesperrt. Bitte entsperren und erneut versuchen."
showUnlockModal = true
}
return
} catch DeviceDetector.DetectorError.profileUserInteractionRequired {
shouldPollEnrollment = true
await MainActor.run {
flowStatus = "Bitte am iPhone Profil bestätigen. Wir prüfen danach automatisch weiter …"
}
break
} catch {
await MainActor.run {
flowStatus = "iOS verlangt Bestätigung am Gerät: \(error.localizedDescription)"
}
break
}
}
if installSucceeded || shouldPollEnrollment {
await waitForEnrollmentReady()
} else {
await MainActor.run {
busy = false
}
}
}
}
private func waitForEnrollmentReady() async {
pollTask?.cancel()
let task = Task {
for _ in 0..<40 {
let profiles = await DeviceDetector.installedProfileIDs()
let hasProfile = profiles.contains(enrollmentProfileID)
let isBackendEnrolled = await checkBackendEnrolled()
if hasProfile, isBackendEnrolled {
await MainActor.run {
busy = false
enrollmentReady = true
model.device?.isEnrolled = true
flowStatus = "✓ Profil aktiv und Server-Enrollment bestätigt."
triggerAutomaticContinue()
}
return
}
if hasProfile {
await MainActor.run {
flowStatus = "Profil aktiv. Warte auf Enrollment-Check-In am Server …"
}
}
try? await Task.sleep(for: .seconds(3))
}
await MainActor.run {
busy = false
flowStatus = "⚠ Enrollment noch nicht vollständig bestätigt. Bitte iPhone-Profil-Dialog prüfen."
}
}
pollTask = task
_ = await task.result
}
private func checkBackendEnrolled() async -> Bool {
guard let udid = model.device?.udid else { return false }
guard let status = try? await MDMStatus.query(udid: udid) else { return false }
await MainActor.run {
model.device?.enrollmentStatus = status
if status.isEnrolled {
model.device?.isEnrolled = true
}
}
return status.isEnrolled
}
@MainActor
private func triggerAutomaticContinue() {
guard enrollmentReady, !didAutoAdvance else { return }
didAutoAdvance = true
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.8))
model.goTo(.configure)
}
}
}