chahinebrini b31066a04c feat(chat): native action sheet + Insta-style heart for DM messages
- ChatBubble: useActionSheet replaces custom Modal (native iOS popup, Android bottom sheet)
- DM mode (isDM prop): hides like-count, shows Insta-style heart badge under bubble when liked
- Group chat unchanged
- Cleanup: remove unused Modal/Platform imports, sheet styles, actionsOpen state
- deploy.sh: auto-detect ANDROID_HOME + auto-create local.properties for local Gradle
- NEXT_RELEASE.md: DM reactions release note
- Includes other staged work across binder-mac, marketing, ops/mdm, ios/
2026-05-30 09:14:32 +02:00

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()
}
}
}