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

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