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
337 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|