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
420 lines
18 KiB
Swift
420 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct ConfigureView: View {
|
|
@Environment(WizardModel.self) private var model
|
|
|
|
@State private var task: Task<Void, Never>?
|
|
@State private var needsPushRetry = false
|
|
@State private var lockProfileConfirmed = false
|
|
@State private var configureReady = false
|
|
@State private var appPushDone = false
|
|
@State private var backendValidationDone = false
|
|
@State private var didAutoFinish = false
|
|
|
|
private let sideloadProfileID = "org.rebreak.protection.contentfilter.sideload"
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
header
|
|
|
|
Text("Wir richten den Schutz jetzt automatisch ein: App-Setup per Push und anschließend Lock-Profil (non-removable) mit automatischer Prüfung.")
|
|
.foregroundStyle(.secondary)
|
|
|
|
TransferAnimationView(
|
|
leftSymbol: "server.rack",
|
|
rightSymbol: "iphone.gen3",
|
|
title: "App-Setup",
|
|
subtitle: appPushDone
|
|
? "ReBreak-App Push/Management bestätigt."
|
|
: "ReBreak-Server pusht App-Setup auf das iPhone.",
|
|
isActive: model.configureRunning && !appPushDone,
|
|
isDone: appPushDone
|
|
)
|
|
|
|
TransferAnimationView(
|
|
leftSymbol: "iphone.gen3",
|
|
rightSymbol: "server.rack",
|
|
title: "Lock + DNS Validierung",
|
|
subtitle: backendValidationDone
|
|
? "Lock-Profil aktiv und Backend-Check-In ist frisch."
|
|
: "Warte auf Lock-Profil und anschließende Backend-Bestätigung.",
|
|
isActive: model.configureRunning && appPushDone && !backendValidationDone,
|
|
isDone: backendValidationDone
|
|
)
|
|
|
|
stepList
|
|
|
|
appPreStatus
|
|
|
|
statusBox
|
|
|
|
if model.showAdvancedLogs {
|
|
logViewer
|
|
}
|
|
|
|
Button(model.showAdvancedLogs ? "Details ausblenden" : "Details anzeigen") {
|
|
model.showAdvancedLogs.toggle()
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
navigationBar
|
|
}
|
|
.padding(40)
|
|
.onAppear { startIfNeeded() }
|
|
.onDisappear { task?.cancel() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Image(systemName: "shield.lefthalf.filled")
|
|
.font(.system(size: 30))
|
|
.foregroundStyle(.tint)
|
|
Text("Schutz aktivieren")
|
|
.font(.title).bold()
|
|
}
|
|
}
|
|
|
|
private var stepList: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label("Automatischer Pre-Check", systemImage: "magnifyingglass")
|
|
Label("App-Setup + Managed-Status per Push", systemImage: "arrow.triangle.branch")
|
|
Label("Lock-Profil (non-removable) anwenden", systemImage: "paperplane")
|
|
Label("Automatische Verifikation", systemImage: "checkmark.seal")
|
|
}
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
/// Pre-Check Status der ReBreak-App auf dem iPhone.
|
|
private var appPreStatus: some View {
|
|
let installed = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true
|
|
return HStack(spacing: 8) {
|
|
Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle")
|
|
.foregroundStyle(installed ? .green : .secondary)
|
|
Text(installed
|
|
? "ReBreak-App ist bereits installiert. Wir setzen jetzt den Managed-Status."
|
|
: "ReBreak-App noch nicht lokal sichtbar. Wir installieren sie jetzt automatisch per Push.")
|
|
.font(.callout)
|
|
}
|
|
.padding(8)
|
|
.background((installed ? Color.green : Color.blue).opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusBox: some View {
|
|
if model.configureRunning {
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Automatischer Schutz-Flow läuft …")
|
|
}
|
|
.padding(10)
|
|
.background(Color.blue.opacity(0.08))
|
|
.cornerRadius(6)
|
|
} else if let err = model.configureError {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "xmark.octagon").foregroundStyle(.red)
|
|
Text(err).font(.callout)
|
|
}
|
|
.padding(10)
|
|
.background(Color.red.opacity(0.08))
|
|
.cornerRadius(6)
|
|
} else if !model.configureLog.isEmpty {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
|
Text("Schutz vollständig validiert. Du kannst abschließen.")
|
|
}
|
|
.padding(10)
|
|
.background(Color.green.opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
}
|
|
|
|
private var logViewer: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 2) {
|
|
ForEach(Array(model.configureLog.enumerated()), id: \.offset) { idx, line in
|
|
Text(line)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(line.hasPrefix("✗") ? .red : .secondary)
|
|
.id(idx)
|
|
}
|
|
}
|
|
.padding(8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.background(Color.black.opacity(0.04))
|
|
.cornerRadius(6)
|
|
.frame(maxHeight: 200)
|
|
.onChange(of: model.configureLog.count) { _, newCount in
|
|
if newCount > 0 { proxy.scrollTo(newCount - 1, anchor: .bottom) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var navigationBar: some View {
|
|
HStack {
|
|
Button("Zurück") { model.goTo(.enroll) }
|
|
.buttonStyle(.bordered)
|
|
.disabled(model.configureRunning)
|
|
Spacer()
|
|
if configureReady {
|
|
Text("Schutz bestätigt. Abschluss wird automatisch geöffnet …")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
Button("Jetzt zu Fertig") { model.advance() }
|
|
.buttonStyle(.borderedProminent)
|
|
} else {
|
|
Text("Bitte kurz warten …")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pfad zur Sideload-Profile-Datei (nicht zur MDM-Push-Variante).
|
|
/// User dropt die per AirDrop an's iPhone.
|
|
private var sideloadProfilePath: String? {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
let candidates = [
|
|
"\(home)/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig",
|
|
]
|
|
return candidates.first(where: { FileManager.default.fileExists(atPath: $0) })
|
|
}
|
|
|
|
/// Öffnet macOS' NSSharingServicePicker mit AirDrop-Service vorausgewählt.
|
|
/// User klickt das eigene iPhone an → File wird übertragen → iPhone fragt nach Install.
|
|
private func sendViaAirDrop(path: String) {
|
|
let url = URL(fileURLWithPath: path)
|
|
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
|
model.configureLog.append("⚠ AirDrop-Service nicht verfügbar — manuell per Finder teilen.")
|
|
return
|
|
}
|
|
if service.canPerform(withItems: [url]) {
|
|
service.perform(withItems: [url])
|
|
model.configureLog.append("→ AirDrop-Sheet geöffnet — wähle dein iPhone aus.")
|
|
} else {
|
|
model.configureLog.append("⚠ AirDrop kann diese Datei nicht senden — manuell per Finder.")
|
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
}
|
|
}
|
|
|
|
private func startIfNeeded() {
|
|
if model.configureLog.isEmpty && !model.configureRunning && model.configureError == nil {
|
|
startConfigure()
|
|
}
|
|
}
|
|
|
|
private func startConfigure() {
|
|
guard let udid = model.device?.udid else {
|
|
model.configureError = "Kein Device — bitte zurück zu Step 1."
|
|
return
|
|
}
|
|
model.configureLog = []
|
|
model.configureError = nil
|
|
model.configureRunning = true
|
|
needsPushRetry = false
|
|
lockProfileConfirmed = false
|
|
configureReady = false
|
|
appPushDone = false
|
|
backendValidationDone = false
|
|
didAutoFinish = false
|
|
task?.cancel()
|
|
task = Task { @MainActor in
|
|
do {
|
|
// PRE-FLIGHT: real-check ob iPhone überhaupt enrolled ist + check-in macht
|
|
model.configureLog.append("→ Pre-Flight: NanoMDM-Enrollment-Status …")
|
|
let status = try await MDMStatus.query(udid: udid)
|
|
if !status.isEnrolled {
|
|
throw NSError(domain: "Binder", code: 1, userInfo: [NSLocalizedDescriptionKey:
|
|
"iPhone ist NICHT in NanoMDM enrolled. Bitte Step 4 (Enroll) wiederholen."])
|
|
}
|
|
let pending = status.pendingCommandCount
|
|
if let ack = status.lastAckAt {
|
|
let ageMin = Int(Date().timeIntervalSince(ack) / 60)
|
|
model.configureLog.append("✓ enrolled · letzter Ack vor \(ageMin) min · \(pending) pending")
|
|
if ageMin > 30 {
|
|
model.configureLog.append("⚠ Letzter Check-In ist alt (>30min). iPhone reagiert evtl. nicht.")
|
|
model.configureLog.append("⚠ Falls Commands nach 1min nicht ausgeführt: Step 4 (Enroll) wiederholen.")
|
|
}
|
|
} else {
|
|
model.configureLog.append("⚠ Noch nie ge-acked. Enrollment vermutlich tot — Step 4 wiederholen.")
|
|
}
|
|
|
|
model.configureLog.append("→ Ping NanoMDM …")
|
|
let version = try await MDMClient.ping()
|
|
model.configureLog.append("✓ NanoMDM \(version.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
|
|
// Marker für Post-Flight: alle Acks NACH diesem Zeitstempel
|
|
// gehören zu unseren Commands. NanoMDM's enrollment_queue.active
|
|
// bleibt nach Ack auf true — daher zählen wir command_results.
|
|
let pushStartTime = Date()
|
|
|
|
// Harte Variante fuer robuste Tests:
|
|
// Wenn ReBreak-App schon da ist, zuerst löschen und dann frisch pushen.
|
|
let appAlreadyInstalled = await DeviceDetector.installedAppBundleIDs().contains("org.rebreak.app")
|
|
if appAlreadyInstalled {
|
|
model.configureLog.append("→ Hard-Reinstall: vorhandene ReBreak-App wird entfernt …")
|
|
try await DeviceDetector.removeApp(bundleID: "org.rebreak.app")
|
|
let removed = await waitForAppInstalled(expectedInstalled: false)
|
|
if !removed {
|
|
throw NSError(domain: "Binder", code: 7, userInfo: [NSLocalizedDescriptionKey:
|
|
"Vorhandene ReBreak-App konnte nicht sicher entfernt werden."])
|
|
}
|
|
model.configureLog.append("✓ Vorhandene ReBreak-App entfernt.")
|
|
} else {
|
|
model.configureLog.append("→ ReBreak-App nicht vorhanden, starte frischen Install-Push.")
|
|
}
|
|
|
|
for attempt in 1...2 {
|
|
model.configureLog.append("→ [1/2] Push-Versuch \(attempt): InstallApplication …")
|
|
let r1 = try await MDMClient.installApp(udid: udid)
|
|
model.configureLog.append("✓ enqueued: \(r1.prefix(80))")
|
|
|
|
model.configureLog.append("→ [2/2] Push-Versuch \(attempt): Settings mdmSupervised=true …")
|
|
let r2 = try await MDMClient.setSupervisedMode(udid: udid)
|
|
model.configureLog.append("✓ enqueued: \(r2.prefix(80))")
|
|
|
|
model.configureLog.append("")
|
|
model.configureLog.append("Warte 30s und prüfe automatische Rückmeldung …")
|
|
|
|
// POST-FLIGHT: 30s warten + checken ob neue Acks NACH pushStartTime da sind
|
|
try? await Task.sleep(for: .seconds(30))
|
|
let after = try await MDMStatus.query(udid: udid)
|
|
let lastAckAfter = after.lastAckAt
|
|
let hasNewAck = (lastAckAfter ?? .distantPast) > pushStartTime
|
|
if !hasNewAck {
|
|
needsPushRetry = true
|
|
model.configureLog.append("⚠ Kein neuer Ack erkannt (Versuch \(attempt)).")
|
|
if attempt == 2 {
|
|
throw NSError(domain: "Binder", code: 2, userInfo: [NSLocalizedDescriptionKey:
|
|
"iPhone hat keine Pushes abgeholt. Bitte Enrollment-Verbindung prüfen."])
|
|
}
|
|
continue
|
|
}
|
|
|
|
model.configureLog.append("✓ iPhone hat ge-acked (\(lastAckAfter!.formatted(date: .omitted, time: .standard))).")
|
|
|
|
let appsAfter = await DeviceDetector.installedAppBundleIDs()
|
|
let isAppInstalled = appsAfter.contains("org.rebreak.app")
|
|
model.configureLog.append(isAppInstalled
|
|
? "✓ ReBreak-App ist auf dem iPhone."
|
|
: "⚠ ReBreak-App noch nicht sichtbar (Versuch \(attempt)).")
|
|
|
|
model.configureLog.append("→ Verifiziere Managed-Status …")
|
|
let managed = try await MDMClient.checkAppIsManaged(udid: udid)
|
|
if isAppInstalled, managed == true {
|
|
model.device?.isManaged = true
|
|
needsPushRetry = false
|
|
appPushDone = true
|
|
break
|
|
}
|
|
needsPushRetry = true
|
|
if attempt == 2 {
|
|
throw NSError(domain: "Binder", code: 3, userInfo: [NSLocalizedDescriptionKey:
|
|
"App-Setup konnte nicht stabil verifiziert werden. Bitte Schritt erneut starten."])
|
|
}
|
|
model.configureLog.append("⚠ Automatischer Retry läuft …")
|
|
}
|
|
|
|
model.configureLog.append("")
|
|
model.configureLog.append("→ [3/3] Installiere non-removable Lock-Profil …")
|
|
guard let profilePath = sideloadProfilePath else {
|
|
throw NSError(domain: "Binder", code: 4, userInfo: [NSLocalizedDescriptionKey:
|
|
"Lock-Profil-Datei nicht gefunden."])
|
|
}
|
|
do {
|
|
try await DeviceDetector.installProfileSilently(path: profilePath)
|
|
model.configureLog.append("✓ Lock-Profil via USB installiert.")
|
|
} catch {
|
|
// Falls cfgutil zwar Fehler liefert, das Profil aber dennoch
|
|
// bereits installiert wurde, kein AirDrop mehr öffnen.
|
|
let alreadyInstalled = await waitForLockProfileInstalled(maxChecks: 4, intervalSeconds: 2)
|
|
if alreadyInstalled {
|
|
model.configureLog.append("✓ Lock-Profil wurde trotz USB-Fehler erkannt. Kein AirDrop nötig.")
|
|
} else {
|
|
model.configureLog.append("⚠ USB-Install nicht möglich: \(error.localizedDescription)")
|
|
model.configureLog.append("→ Öffne AirDrop-Fallback für das Lock-Profil …")
|
|
sendViaAirDrop(path: profilePath)
|
|
}
|
|
}
|
|
|
|
let lockInstalled = await waitForLockProfileInstalled()
|
|
if !lockInstalled {
|
|
throw NSError(domain: "Binder", code: 5, userInfo: [NSLocalizedDescriptionKey:
|
|
"Lock-Profil wurde noch nicht erkannt. Bitte iPhone-Dialog abschließen."])
|
|
}
|
|
lockProfileConfirmed = true
|
|
model.device?.isFilterActive = true
|
|
|
|
model.configureLog.append("→ Validiere frischen Backend-Check-In …")
|
|
let backendOk = await waitForFreshBackendStatus(udid: udid)
|
|
if !backendOk {
|
|
throw NSError(domain: "Binder", code: 6, userInfo: [NSLocalizedDescriptionKey:
|
|
"Backend-Bestätigung für aktiven Schutz fehlt noch. Bitte kurz warten und erneut versuchen."])
|
|
}
|
|
backendValidationDone = true
|
|
|
|
configureReady = true
|
|
model.configureLog.append("✓ Lock-Profil ist aktiv erkannt.")
|
|
model.configureLog.append("✓ Backend-Status bestätigt aktiven Schutz.")
|
|
model.configureRunning = false
|
|
triggerAutomaticFinish()
|
|
} catch {
|
|
model.configureLog.append("✗ Fehler: \(error.localizedDescription)")
|
|
model.configureError = error.localizedDescription
|
|
model.configureRunning = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func waitForLockProfileInstalled(maxChecks: Int = 40, intervalSeconds: UInt64 = 3) async -> Bool {
|
|
for _ in 0..<maxChecks {
|
|
let ids = await DeviceDetector.installedProfileIDs()
|
|
if ids.contains(sideloadProfileID) {
|
|
return true
|
|
}
|
|
try? await Task.sleep(for: .seconds(intervalSeconds))
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func waitForAppInstalled(expectedInstalled: Bool) async -> Bool {
|
|
for _ in 0..<20 {
|
|
let installed = await DeviceDetector.installedAppBundleIDs().contains("org.rebreak.app")
|
|
if installed == expectedInstalled {
|
|
return true
|
|
}
|
|
try? await Task.sleep(for: .seconds(2))
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func waitForFreshBackendStatus(udid: String) async -> Bool {
|
|
for _ in 0..<30 {
|
|
if let status = try? await MDMStatus.query(udid: udid) {
|
|
model.device?.enrollmentStatus = status
|
|
if status.isEnrolled && status.isFresh {
|
|
return true
|
|
}
|
|
}
|
|
try? await Task.sleep(for: .seconds(3))
|
|
}
|
|
return false
|
|
}
|
|
|
|
@MainActor
|
|
private func triggerAutomaticFinish() {
|
|
guard configureReady, !didAutoFinish else { return }
|
|
didAutoFinish = true
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(0.8))
|
|
model.advance()
|
|
}
|
|
}
|
|
}
|