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
362 lines
14 KiB
Swift
362 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
private enum DebugSupervisionMode: String, CaseIterable, Identifiable {
|
|
case none
|
|
case forceSupervised
|
|
case forceUnsupervised
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .none: return "Kein Mode-Change"
|
|
case .forceSupervised: return "Supervised setzen"
|
|
case .forceUnsupervised: return "Unsupervised setzen"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WelcomeView: View {
|
|
@Environment(WizardModel.self) private var model
|
|
|
|
@State private var detecting = false
|
|
@State private var error: String?
|
|
@State private var pollTask: Task<Void, Never>?
|
|
@State private var resetRunning = false
|
|
@State private var resetStatus: String?
|
|
@State private var resetAll = true
|
|
@State private var resetEnrollmentProfile = true
|
|
@State private var resetLockProfile = true
|
|
@State private var resetApp = true
|
|
@State private var supervisionMode: DebugSupervisionMode = .none
|
|
|
|
var body: some View {
|
|
VStack(spacing: 24) {
|
|
Image(systemName: "iphone.gen3")
|
|
.font(.system(size: 80))
|
|
.foregroundStyle(.tint)
|
|
|
|
Text("iPhone via USB verbinden")
|
|
.font(.title)
|
|
.bold()
|
|
|
|
Text("Stecke dein iPhone per USB-C-Kabel an deinen Mac. Falls ein „Diesem Computer vertrauen?\"-Dialog erscheint, tippe auf **Vertrauen** + gib deinen iPhone-Code ein.")
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 40)
|
|
|
|
if let device = model.device {
|
|
deviceCard(device)
|
|
} else if detecting {
|
|
ProgressView("Suche iPhone …")
|
|
.progressViewStyle(.circular)
|
|
} else if let error {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundStyle(.orange)
|
|
Text(error).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
}
|
|
.padding()
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Erneut suchen") { startDetection() }
|
|
.buttonStyle(.bordered)
|
|
.disabled(detecting)
|
|
|
|
Button(nextButtonLabel) { handleNext() }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(model.device == nil)
|
|
}
|
|
|
|
resetSection
|
|
}
|
|
.padding(40)
|
|
.onAppear { startDetection() }
|
|
.onDisappear { pollTask?.cancel() }
|
|
}
|
|
|
|
private var resetSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Divider()
|
|
Text("Interner Test-Reset")
|
|
.font(.headline)
|
|
Text("Wähle gezielt, was entfernt werden soll. Optional kann zusätzlich supervised/unsupervised für Tests gesetzt werden.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Toggle("Alles entfernen (Profile + App)", isOn: $resetAll)
|
|
.toggleStyle(.checkbox)
|
|
.onChange(of: resetAll) { _, newValue in
|
|
if newValue {
|
|
resetEnrollmentProfile = true
|
|
resetLockProfile = true
|
|
resetApp = true
|
|
}
|
|
}
|
|
|
|
Group {
|
|
Toggle("MDM Enrollment-Profil löschen", isOn: $resetEnrollmentProfile)
|
|
.toggleStyle(.checkbox)
|
|
Toggle("Lock-Profil löschen", isOn: $resetLockProfile)
|
|
.toggleStyle(.checkbox)
|
|
Toggle("ReBreak-App löschen", isOn: $resetApp)
|
|
.toggleStyle(.checkbox)
|
|
}
|
|
.disabled(resetAll)
|
|
|
|
Picker("Test-Mode", selection: $supervisionMode) {
|
|
ForEach(DebugSupervisionMode.allCases) { mode in
|
|
Text(mode.title).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
if let resetStatus {
|
|
Text(resetStatus)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
if resetRunning {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
Button("Debug-Reset ausführen") {
|
|
startDebugReset()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(model.device == nil || resetRunning || detecting)
|
|
}
|
|
}
|
|
.frame(maxWidth: 520, alignment: .leading)
|
|
}
|
|
|
|
private var nextButtonLabel: String {
|
|
if model.device?.isFullyBound == true {
|
|
return "Weiter → Schutz aktivieren"
|
|
}
|
|
if model.device?.isOwnedByReBreak == true {
|
|
return "Weiter → MDM neu enrollen"
|
|
}
|
|
return "Weiter"
|
|
}
|
|
|
|
private func handleNext() {
|
|
// Smart-Resume mit echter Validation:
|
|
// - isFullyBound (supervised + recent MDM-ack) → skip zu Configure
|
|
// - isOwnedByReBreak aber MDM-channel tot → skip zu Enroll
|
|
// - sonst normaler Wizard-Flow
|
|
if model.device?.isFullyBound == true {
|
|
model.goTo(.configure)
|
|
} else if model.device?.isOwnedByReBreak == true {
|
|
model.goTo(.enroll)
|
|
} else {
|
|
model.advance()
|
|
}
|
|
}
|
|
|
|
private func deviceCard(_ d: DeviceState) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text(d.deviceName).font(.headline)
|
|
}
|
|
Text("\(d.displayModel) · iOS \(d.productVersion)")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
Text("UDID: \(d.udid)")
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
|
|
if d.isFullyBound {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "checkmark.shield.fill")
|
|
.foregroundStyle(.green)
|
|
Text("Vollständig durch **ReBreak** gebunden")
|
|
.font(.callout)
|
|
.foregroundStyle(.green)
|
|
}
|
|
.padding(.top, 4)
|
|
if let ack = d.enrollmentStatus?.lastAckAt {
|
|
Text("Letzter MDM-Check-In: \(ack.formatted(date: .abbreviated, time: .shortened))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("Wir überspringen Supervise + Enroll und gehen direkt zum Configure-Step.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else if d.isOwnedByReBreak && !d.hasEnrollmentProfile {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.shield")
|
|
.foregroundStyle(.orange)
|
|
Text("Supervised, **aber Enrollment-Profil fehlt**")
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(.top, 4)
|
|
Text("iPhone braucht MDM-Profil-Installation (Step 4).")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else if d.isOwnedByReBreak {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "shield.lefthalf.filled")
|
|
.foregroundStyle(.orange)
|
|
Text("Supervised by ReBreak, **MDM-Kanal stumm**")
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(.top, 4)
|
|
if let ack = d.enrollmentStatus?.lastAckAt {
|
|
Text("Letzter Check-In: \(ack.formatted(date: .abbreviated, time: .shortened)) — älter als 30min")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Kein MDM-Check-In aufgezeichnet. iPhone neu enrollen.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if d.isSupervised == true, let org = d.supervisorOrgName {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.shield")
|
|
.foregroundStyle(.orange)
|
|
Text("Supervised by „\(org)\" (nicht ReBreak)")
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.padding(.top, 4)
|
|
Text("Wir überschreiben das beim Supervise-Step.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: 400, alignment: .leading)
|
|
.background(Color.green.opacity(0.08))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
private func startDetection() {
|
|
pollTask?.cancel()
|
|
detecting = true
|
|
error = nil
|
|
pollTask = Task {
|
|
do {
|
|
var device = try await DeviceDetector.detect()
|
|
// Smart-Resume Layer 1: supervised + by ReBreak?
|
|
let status = await DeviceDetector.readSupervisionStatus()
|
|
device.isSupervised = status.isSupervised
|
|
device.supervisorOrgName = status.organizationName
|
|
device.isFmiOn = status.findMyEnabled
|
|
|
|
// Smart-Resume Layer 2: MDM-Channel real lebendig?
|
|
// Auch wenn supervised, kann re-supervise das MDM-Enrollment
|
|
// gekillt haben. Daher DB-Real-Check + cfgutil-Ground-Truth.
|
|
if let enrollment = try? await MDMStatus.query(udid: device.udid) {
|
|
device.enrollmentStatus = enrollment
|
|
device.isEnrolled = enrollment.isEnrolled
|
|
}
|
|
// Smart-Resume Layer 3: ist das Enrollment-Profil REAL auf iPhone?
|
|
// (cfgutil-Liste — User kann Profil manuell entfernt haben,
|
|
// NanoMDM-DB merkt das erst beim nächsten APNs-Cycle.)
|
|
device.installedProfileIDs = await DeviceDetector.installedProfileIDs()
|
|
device.installedAppBundleIDs = await DeviceDetector.installedAppBundleIDs()
|
|
|
|
await MainActor.run {
|
|
model.device = device
|
|
detecting = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.error = error.localizedDescription
|
|
self.detecting = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startDebugReset() {
|
|
guard model.device != nil else {
|
|
resetStatus = "Kein iPhone erkannt."
|
|
return
|
|
}
|
|
resetRunning = true
|
|
resetStatus = "Führe Debug-Reset aus …"
|
|
|
|
Task {
|
|
do {
|
|
var changes: [String] = []
|
|
|
|
let removeEnrollment = resetAll || resetEnrollmentProfile
|
|
let removeLock = resetAll || resetLockProfile
|
|
let removeApp = resetAll || resetApp
|
|
|
|
let installedProfileIDs = await DeviceDetector.installedProfileIDs()
|
|
var profileIDs: [String] = []
|
|
if removeEnrollment, installedProfileIDs.contains(DeviceState.enrollmentProfileID) {
|
|
profileIDs.append(DeviceState.enrollmentProfileID)
|
|
}
|
|
if removeLock, installedProfileIDs.contains(DeviceState.lockProfileID) {
|
|
profileIDs.append(DeviceState.lockProfileID)
|
|
}
|
|
if !profileIDs.isEmpty {
|
|
try await DeviceDetector.removeProfiles(identifiers: profileIDs)
|
|
changes.append("Profile gelöscht: \(profileIDs.joined(separator: ", "))")
|
|
}
|
|
|
|
if removeApp {
|
|
try await DeviceDetector.removeApp(bundleID: "org.rebreak.app")
|
|
changes.append("App gelöscht: org.rebreak.app")
|
|
}
|
|
|
|
switch supervisionMode {
|
|
case .forceSupervised:
|
|
_ = try await SuperviseRunner.supervise(verbose: false) { _ in }
|
|
changes.append("Mode gesetzt: supervised")
|
|
case .forceUnsupervised:
|
|
_ = try await SuperviseRunner.unsupervise { _ in }
|
|
changes.append("Mode gesetzt: unsupervised")
|
|
case .none:
|
|
break
|
|
}
|
|
|
|
let nowInstalledProfiles = await DeviceDetector.installedProfileIDs()
|
|
let nowApps = await DeviceDetector.installedAppBundleIDs()
|
|
let status = await DeviceDetector.readSupervisionStatus()
|
|
|
|
await MainActor.run {
|
|
if changes.isEmpty {
|
|
resetStatus = "Keine Aktion gewählt."
|
|
} else {
|
|
resetStatus = "✓ \(changes.joined(separator: " · "))"
|
|
}
|
|
|
|
if var device = model.device {
|
|
device.installedProfileIDs = nowInstalledProfiles
|
|
device.installedAppBundleIDs = nowApps
|
|
device.isSupervised = status.isSupervised
|
|
device.supervisorOrgName = status.organizationName
|
|
device.isFmiOn = status.findMyEnabled
|
|
device.isEnrolled = nowInstalledProfiles.contains(DeviceState.enrollmentProfileID)
|
|
if !nowApps.contains("org.rebreak.app") { device.isManaged = false }
|
|
if !nowInstalledProfiles.contains(DeviceState.lockProfileID) { device.isFilterActive = false }
|
|
model.device = device
|
|
}
|
|
|
|
resetRunning = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
resetStatus = "✗ Reset fehlgeschlagen: \(error.localizedDescription)"
|
|
resetRunning = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|