diff --git a/android-shot.png b/android-shot.png new file mode 100644 index 0000000..e69de29 diff --git a/app.json b/app.json new file mode 100644 index 0000000..879ea76 --- /dev/null +++ b/app.json @@ -0,0 +1,5 @@ +{ + "ios": { + "bundleIdentifier": "com.chahinebrini.rebreak-monorepo" + } +} diff --git a/apps/marketing/app/locales/de.json b/apps/marketing/app/locales/de.json index a6886f1..ac314de 100644 --- a/apps/marketing/app/locales/de.json +++ b/apps/marketing/app/locales/de.json @@ -13,7 +13,7 @@ "cta_start": "Jetzt kostenlos starten", "stat_affected": "Menschen in DE betroffen", "stat_blocked": "Domains geblockt", - "stat_free": "Zum Starten", + "stat_from": "Ab pro Monat", "more_info": "Mehr erfahren", "blocker_badge": "Gambling Blocker", "blocker_title_domains": "Domains.", @@ -146,7 +146,7 @@ "quotes_subtitle": "Von Psychologen und Denkern über Selbstschutz und Veränderung", "faq_title": "Häufige Fragen", "cta_title": "Bereit anzufangen?", - "cta_desc": "Kostenlos starten, jederzeit upgraden.", + "cta_desc": "14 Tage gratis testen, jederzeit kündbar.", "cta_button": "App herunterladen", "footer_home": "Home", "footer_pricing": "Preise", @@ -158,73 +158,70 @@ "billing_forever": "für immer", "billing_per_month": "/ Monat", "billing_per_year": "/ Monat, jährlich", - "plan_free_title": "Kostenlos", - "plan_free_desc": "Einstieg ohne Risiko – für immer gratis.", - "plan_free_btn": "App herunterladen", "plan_pro_title": "Pro", - "plan_pro_desc": "Vollständiger Schutz und alle Tools für deinen Alltag.", + "plan_pro_desc": "Voller Schutz für ein Gerät – alles, was du im Alltag brauchst.", "plan_pro_btn": "Pro starten", "plan_legend_title": "Legend", - "plan_legend_desc": "Für die, die stark genug sind – um anderen den Weg zu ebnen.", + "plan_legend_desc": "Maximaler Schutz für bis zu 3 Geräte – inkl. Selbstbindungs-Modus.", "plan_legend_btn": "Legend starten", "plan_loading": "Wird geladen...", "plan_recommended": "Empfohlen", - "feat_free_domains": "5 eigene Domains", - "feat_free_mail": "1 Mail-Agent (Scan alle 4h)", - "feat_coach_basic": "KI-Coach Basis", + "feat_pro_devices": "1 Gerät (iOS, Android oder macOS)", + "feat_pro_domains": "5 eigene Domains (rückfüllbar)", + "feat_pro_mail": "Echtzeit-Mail-Schutz (IMAP-IDLE, 2 Konten)", + "feat_blocklist": "ReBreak Blocklist (208k+ Domains)", + "feat_coach_pro": "KI-Coach Lyra mit Streak & Urge-Statistiken", "feat_streak": "Streak & Ersparnisse Tracker", "feat_urge": "Urge Tracker + Atemübung", "feat_sos": "SOS-Button (Sofort-Hilfe)", "feat_community": "Gemeinschaft erleben", - "feat_all_free": "Alles aus Kostenlos", - "feat_blocklist": "ReBreak Blocklist (208k+ Domains)", - "feat_pro_domains": "5 eigene Domains (rückfüllbar)", - "feat_pro_mail": "3 Mail-Agenten (Intervall: 1h / 4h / 8h)", - "feat_community_post": "Community posten", + "feat_community_post": "Community posten + Buddy-System", "feat_buddy": "Buddy System", - "feat_coach_pro": "KI-Coach (besser)", "feat_urge_stats": "Urge-Statistiken & Muster", "feat_all_pro": "Alles aus Pro", - "feat_legend_domains": "Unbegrenzte eigene Domains (rückfüllbar)", - "feat_legend_mail": "Unbegrenzte Mail-Agenten (Echtzeit)", + "feat_legend_devices": "Bis zu 3 Geräte (iOS, Android, macOS)", + "feat_legend_domains": "Unbegrenzte eigene Domains", + "feat_legend_mail": "Echtzeit-Mail-Schutz (IMAP-IDLE, unbegrenzte Konten)", + "feat_legend_binder": "RebReakBinder – Selbstbindungs-Modus (opt-in, macOS)", "feat_legend_add": "Domains direkt zur ReBreak Blocklist hinzufügen", "feat_legend_validate": "Community-Domains validieren", "feat_legend_groups": "Gruppen gründen & leiten", - "feat_coach_legend": "Top KI-Coach mit Gedächtnis", + "feat_coach_legend": "Top KI-Coach mit Langzeit-Gedächtnis", + "comp_devices": "Geräte", "comp_domains": "Eigene Domains", - "comp_mail": "Mail-Agent", - "comp_coach": "KI-Coach", + "comp_mail": "Mail-Schutz", + "comp_coach": "KI-Coach Lyra", + "comp_blocklist": "ReBreak Blocklist (208k+ Domains)", "comp_streak": "Streak & Ersparnisse Tracker", "comp_urge": "Urge Tracker + Atemübung", "comp_sos": "SOS-Button (Sofort-Hilfe)", "comp_community": "Gemeinschaft erleben", - "comp_blocklist": "ReBreak Blocklist (208k+ Domains)", "comp_post": "Community posten", "comp_buddy": "Buddy System", "comp_urge_stats": "Urge-Statistiken & Muster", + "comp_binder": "RebReakBinder (Selbstbindungs-Modus, macOS)", "comp_add_domain": "Domains zur Blocklist hinzufügen", "comp_validate": "Community-Domains validieren", "comp_groups": "Gruppen gründen & leiten", - "comp_free_domains": "5", + "comp_pro_devices": "1", + "comp_legend_devices": "bis 3", "comp_pro_domains": "5 (rückfüllbar)", - "comp_legend_domains": "Unbegrenzt (rückfüllbar)", - "comp_free_mail_val": "1 (4h)", - "comp_pro_mail_val": "3 (1h / 4h / 8h)", - "comp_legend_mail_val": "Echtzeit", - "comp_free_coach_val": "Basis", - "comp_pro_coach_val": "Besser", - "comp_legend_coach_val": "Top + Gedächtnis", + "comp_legend_domains": "Unbegrenzt", + "comp_pro_mail_val": "Echtzeit · 2 Konten", + "comp_legend_mail_val": "Echtzeit · unbegrenzt", + "comp_pro_coach_val": "Streak + Urge-Stats", + "comp_legend_coach_val": "+ Langzeit-Gedächtnis", "faq1_q": "Muss ich eine E-Mail-Adresse angeben?", "faq1_a": "Ja, für die Registrierung wird eine E-Mail-Adresse benötigt. Deine Daten werden ausschließlich auf deutschen Servern gespeichert und verarbeitet – vollständig anonym, nach strengen DSGVO-Standards. Kein Name, kein Standort, kein Nutzungsverhalten wird an Dritte weitergegeben.", "faq2_q": "Was ist der Unterschied zwischen Pro und Legend?", - "faq2_a": "Pro gibt dir vollständigen Schutz: ReBreak Blocklist (208k+ Domains), 3 Mail-Agenten, KI-Coach und Community. Legend geht weiter: unbegrenzte Domains und Agenten, direktes Hinzufügen zur Blocklist, Validierung von Community-Domains, Gruppen leiten und Top KI-Coach mit Gedächtnis.", + "faq2_a": "Pro schützt EIN Gerät mit Echtzeit-Mail-Schutz (IMAP-IDLE, 2 Konten), ReBreak Blocklist (208k+ Domains) und Lyra mit Streak/Urge-Stats. Legend deckt BIS ZU 3 Geräte ab, hat unbegrenzte Mail-Konten und Domains, schaltet den RebReakBinder-Selbstbindungs-Modus (macOS) frei und gibt dir Lyra mit Langzeit-Gedächtnis sowie Gruppen-Leitung.", "faq3_q": "Welche Zahlungszyklen gibt es?", "faq3_a": "Monatlich (voller Preis) oder jährlich (Spare 39%). Du kannst jederzeit wechseln.", "faq4_q": "Kann ich jederzeit kündigen?", "faq4_a": "Ja, du kannst dein Abo jederzeit kündigen. Du behältst den Zugang bis zum Ende der bezahlten Periode.", - "faq5_q": "Was passiert mit meinen Daten wenn ich kündige?", - "faq5_a": "Dein Account und alle Daten bleiben erhalten. Dein Streak und alle Tracker gehören dir – für immer.", + "faq5_q": "Was ist der RebReakBinder?", + "faq5_a": "Der RebReakBinder ist ein optionaler Selbstbindungs-Modus auf macOS (Legend exklusiv). Er bindet die Schutz-App so an dein Gerät, dass du sie im akuten Druck NICHT einfach selbst deinstallieren kannst – nur eine Vertrauensperson kann lösen. Vollständig opt-in, jederzeit umkehrbar mit Bedenkzeit.", "faq6_q": "Ist ReBreak ein Ersatz für professionelle Hilfe?", - "faq6_a": "Nein. ReBreak ist ein Selbsthilfe-Tool. Bei Krisen: BZgA (0800 1372700) oder Arzt aufsuchen." + "faq6_a": "Nein. ReBreak ist ein Selbsthilfe-Tool. Bei Krisen: BZgA Sucht & Drogen Hotline 0800 1372700 oder Telefonseelsorge 0800 1110 111." } } diff --git a/apps/marketing/app/locales/en.json b/apps/marketing/app/locales/en.json index db87ff6..b3077ef 100644 --- a/apps/marketing/app/locales/en.json +++ b/apps/marketing/app/locales/en.json @@ -13,7 +13,7 @@ "cta_start": "Start free now", "stat_affected": "People in DE affected", "stat_blocked": "Domains blocked", - "stat_free": "To start", + "stat_from": "From / month", "more_info": "Learn more", "blocker_badge": "Gambling Blocker", "blocker_title_domains": "Domains.", @@ -146,7 +146,7 @@ "quotes_subtitle": "From psychologists and thinkers on self-protection and change", "faq_title": "Frequently Asked Questions", "cta_title": "Ready to start?", - "cta_desc": "Start free, upgrade anytime.", + "cta_desc": "14-day free trial, cancel anytime.", "cta_button": "Download the App", "footer_home": "Home", "footer_pricing": "Pricing", @@ -158,72 +158,69 @@ "billing_forever": "forever", "billing_per_month": "/ month", "billing_per_year": "/ month, billed yearly", - "plan_free_title": "Free", - "plan_free_desc": "Get started with no risk – free forever.", - "plan_free_btn": "Download App", "plan_pro_title": "Pro", - "plan_pro_desc": "Full protection and all tools for your daily life.", + "plan_pro_desc": "Full protection for one device – everything you need day to day.", "plan_pro_btn": "Start Pro", "plan_legend_title": "Legend", - "plan_legend_desc": "For those strong enough to light the way for others.", + "plan_legend_desc": "Maximum protection for up to 3 devices – incl. self-binding mode.", "plan_legend_btn": "Start Legend", "plan_loading": "Loading...", "plan_recommended": "Recommended", - "feat_free_domains": "5 custom domains", - "feat_free_mail": "1 mail agent (scan every 4h)", - "feat_coach_basic": "AI Coach Basic", + "feat_pro_devices": "1 device (iOS, Android or macOS)", + "feat_pro_domains": "5 custom domains (refillable)", + "feat_pro_mail": "Real-time mail protection (IMAP IDLE, 2 accounts)", + "feat_blocklist": "ReBreak Blocklist (208k+ domains)", + "feat_coach_pro": "AI Coach Lyra with streak & urge stats", "feat_streak": "Streak & Savings Tracker", "feat_urge": "Urge Tracker + Breathing Exercise", "feat_sos": "SOS Button (Instant Help)", "feat_community": "Experience the community", - "feat_all_free": "Everything in Free", - "feat_blocklist": "ReBreak Blocklist (208k+ domains)", - "feat_pro_domains": "5 custom domains (refillable)", - "feat_pro_mail": "3 mail agents (interval: 1h / 4h / 8h)", - "feat_community_post": "Post in community", + "feat_community_post": "Post in community + Buddy System", "feat_buddy": "Buddy System", - "feat_coach_pro": "AI Coach (Better)", "feat_urge_stats": "Urge statistics & patterns", "feat_all_pro": "Everything in Pro", - "feat_legend_domains": "Unlimited custom domains (refillable)", - "feat_legend_mail": "Unlimited mail agents (real-time)", + "feat_legend_devices": "Up to 3 devices (iOS, Android, macOS)", + "feat_legend_domains": "Unlimited custom domains", + "feat_legend_mail": "Real-time mail protection (IMAP IDLE, unlimited accounts)", + "feat_legend_binder": "RebReakBinder – self-binding mode (opt-in, macOS)", "feat_legend_add": "Add domains directly to the ReBreak Blocklist", "feat_legend_validate": "Validate community domains", "feat_legend_groups": "Create & lead groups", - "feat_coach_legend": "Top AI Coach with memory", + "feat_coach_legend": "Top AI Coach with long-term memory", + "comp_devices": "Devices", "comp_domains": "Custom Domains", - "comp_mail": "Mail Agent", - "comp_coach": "AI Coach", + "comp_mail": "Mail Protection", + "comp_coach": "AI Coach Lyra", + "comp_blocklist": "ReBreak Blocklist (208k+ domains)", "comp_streak": "Streak & Savings Tracker", "comp_urge": "Urge Tracker + Breathing", "comp_sos": "SOS Button (Instant Help)", "comp_community": "Experience community", - "comp_blocklist": "ReBreak Blocklist (208k+ domains)", "comp_post": "Post in community", "comp_buddy": "Buddy System", "comp_urge_stats": "Urge statistics & patterns", + "comp_binder": "RebReakBinder (self-binding mode, macOS)", "comp_add_domain": "Add domains to blocklist", "comp_validate": "Validate community domains", "comp_groups": "Create & lead groups", - "comp_free_domains": "5", + "comp_pro_devices": "1", + "comp_legend_devices": "up to 3", "comp_pro_domains": "5 (refillable)", - "comp_legend_domains": "Unlimited (refillable)", - "comp_free_mail_val": "1 (4h)", - "comp_pro_mail_val": "3 (1h / 4h / 8h)", - "comp_legend_mail_val": "Real-time", - "comp_free_coach_val": "Basic", - "comp_pro_coach_val": "Better", - "comp_legend_coach_val": "Top + Memory", + "comp_legend_domains": "Unlimited", + "comp_pro_mail_val": "Real-time · 2 accounts", + "comp_legend_mail_val": "Real-time · unlimited", + "comp_pro_coach_val": "Streak + Urge Stats", + "comp_legend_coach_val": "+ Long-term memory", "faq1_q": "Do I need to provide an email address?", "faq1_a": "Yes, an email address is required for registration. Your data is stored and processed exclusively on German servers – fully anonymously, according to strict GDPR standards.", "faq2_q": "What's the difference between Pro and Legend?", - "faq2_a": "Pro gives you full protection: ReBreak Blocklist (208k+ domains), 3 mail agents, AI Coach and community. Legend goes further: unlimited domains, direct blocklist additions, domain validation, group leadership and top AI Coach with memory.", + "faq2_a": "Pro protects ONE device with real-time mail protection (IMAP IDLE, 2 accounts), ReBreak Blocklist (208k+ domains) and Lyra with streak/urge stats. Legend covers UP TO 3 devices, has unlimited mail accounts and domains, unlocks the RebReakBinder self-binding mode (macOS) and gives you Lyra with long-term memory plus group leadership.", "faq3_q": "What billing cycles are available?", "faq3_a": "Monthly (full price) or yearly (save 39%). You can switch at any time.", "faq4_q": "Can I cancel at any time?", "faq4_a": "Yes, you can cancel your subscription at any time. You keep access until the end of the paid period.", - "faq5_q": "What happens to my data when I cancel?", - "faq5_a": "Your account and all data remain intact. Your streak and all trackers belong to you – forever.", + "faq5_q": "What is the RebReakBinder?", + "faq5_a": "The RebReakBinder is an optional self-binding mode on macOS (Legend exclusive). It binds the protection app to your device so you CANNOT uninstall it yourself under acute pressure – only a trusted person can release it. Fully opt-in, reversible at any time with a cooling-off period.", "faq6_q": "Is ReBreak a substitute for professional help?", "faq6_a": "No. ReBreak is a self-help tool. In crises: contact a professional or call a helpline." } diff --git a/apps/marketing/app/pages/index.vue b/apps/marketing/app/pages/index.vue index 2e632c2..532ed18 100644 --- a/apps/marketing/app/pages/index.vue +++ b/apps/marketing/app/pages/index.vue @@ -49,8 +49,8 @@
{{ $t('landing.stat_blocked') }}
-
0€
-
{{ $t('landing.stat_free') }}
+
3,99€
+
{{ $t('landing.stat_from') }}
diff --git a/apps/marketing/app/pages/pricing.vue b/apps/marketing/app/pages/pricing.vue index a8b4863..c783b75 100644 --- a/apps/marketing/app/pages/pricing.vue +++ b/apps/marketing/app/pages/pricing.vue @@ -75,9 +75,6 @@ {{ $t('pricing.feature') }} - - {{ $t('pricing.free') }} - Pro Legend @@ -86,11 +83,6 @@ {{ row.label }} - - - {{ row.free }} - - @@ -191,28 +183,6 @@ const billingCycleLabel = computed(() => { const appStoreUrl = "https://apps.apple.com/app/rebreak"; const plans = computed(() => [ - { - title: t('pricing.plan_free_title'), - description: t('pricing.plan_free_desc'), - price: "0€", - billingCycle: t('pricing.billing_forever'), - features: [ - t('pricing.feat_free_domains'), - t('pricing.feat_free_mail'), - t('pricing.feat_coach_basic'), - t('pricing.feat_streak'), - t('pricing.feat_urge'), - t('pricing.feat_sos'), - t('pricing.feat_community'), - ], - button: { - label: t('pricing.plan_free_btn'), - to: appStoreUrl, - target: "_blank", - color: "neutral" as const, - variant: "outline" as const, - }, - }, { title: t('pricing.plan_pro_title'), description: t('pricing.plan_pro_desc'), @@ -221,14 +191,14 @@ const plans = computed(() => [ scale: true, badge: t('pricing.plan_recommended'), features: [ - t('pricing.feat_all_free'), + t('pricing.feat_pro_devices'), t('pricing.feat_blocklist'), t('pricing.feat_pro_domains'), t('pricing.feat_pro_mail'), - t('pricing.feat_community_post'), - t('pricing.feat_buddy'), t('pricing.feat_coach_pro'), + t('pricing.feat_streak'), t('pricing.feat_urge_stats'), + t('pricing.feat_community_post'), ], button: { label: t('pricing.plan_pro_btn'), @@ -243,8 +213,10 @@ const plans = computed(() => [ billingCycle: billingCycleLabel.value, features: [ t('pricing.feat_all_pro'), + t('pricing.feat_legend_devices'), t('pricing.feat_legend_domains'), t('pricing.feat_legend_mail'), + t('pricing.feat_legend_binder'), t('pricing.feat_legend_add'), t('pricing.feat_legend_validate'), t('pricing.feat_legend_groups'), @@ -261,20 +233,22 @@ const plans = computed(() => [ ]); const comparisonRows = computed(() => [ - { label: t('pricing.comp_domains'), free: t('pricing.comp_free_domains'), pro: t('pricing.comp_pro_domains'), legend: t('pricing.comp_legend_domains') }, - { label: t('pricing.comp_mail'), free: t('pricing.comp_free_mail_val'), pro: t('pricing.comp_pro_mail_val'), legend: t('pricing.comp_legend_mail_val') }, - { label: t('pricing.comp_coach'), free: t('pricing.comp_free_coach_val'), pro: t('pricing.comp_pro_coach_val'), legend: t('pricing.comp_legend_coach_val') }, - { label: t('pricing.comp_streak'), free: true, pro: true, legend: true }, - { label: t('pricing.comp_urge'), free: true, pro: true, legend: true }, - { label: t('pricing.comp_sos'), free: true, pro: true, legend: true }, - { label: t('pricing.comp_community'), free: true, pro: true, legend: true }, - { label: t('pricing.comp_blocklist'), free: false, pro: true, legend: true }, - { label: t('pricing.comp_post'), free: false, pro: true, legend: true }, - { label: t('pricing.comp_buddy'), free: false, pro: true, legend: true }, - { label: t('pricing.comp_urge_stats'), free: false, pro: true, legend: true }, - { label: t('pricing.comp_add_domain'), free: false, pro: false, legend: true }, - { label: t('pricing.comp_validate'), free: false, pro: false, legend: true }, - { label: t('pricing.comp_groups'), free: false, pro: false, legend: true }, + { label: t('pricing.comp_devices'), pro: t('pricing.comp_pro_devices'), legend: t('pricing.comp_legend_devices') }, + { label: t('pricing.comp_domains'), pro: t('pricing.comp_pro_domains'), legend: t('pricing.comp_legend_domains') }, + { label: t('pricing.comp_mail'), pro: t('pricing.comp_pro_mail_val'), legend: t('pricing.comp_legend_mail_val') }, + { label: t('pricing.comp_coach'), pro: t('pricing.comp_pro_coach_val'), legend: t('pricing.comp_legend_coach_val') }, + { label: t('pricing.comp_blocklist'), pro: true, legend: true }, + { label: t('pricing.comp_streak'), pro: true, legend: true }, + { label: t('pricing.comp_urge'), pro: true, legend: true }, + { label: t('pricing.comp_sos'), pro: true, legend: true }, + { label: t('pricing.comp_community'), pro: true, legend: true }, + { label: t('pricing.comp_post'), pro: true, legend: true }, + { label: t('pricing.comp_buddy'), pro: true, legend: true }, + { label: t('pricing.comp_urge_stats'), pro: true, legend: true }, + { label: t('pricing.comp_binder'), pro: false, legend: true }, + { label: t('pricing.comp_add_domain'), pro: false, legend: true }, + { label: t('pricing.comp_validate'), pro: false, legend: true }, + { label: t('pricing.comp_groups'), pro: false, legend: true }, ]); const quotes = [ diff --git a/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift b/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift index bc3a194..40a024c 100644 --- a/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift +++ b/apps/rebreak-binder-mac/Sources/Models/DeviceState.swift @@ -23,7 +23,15 @@ struct DeviceState: Equatable { static let lockProfileID = "org.rebreak.protection.contentfilter.sideload" var isOwnedByReBreak: Bool { - (isSupervised == true) && (supervisorOrgName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) + (isSupervised == true) && (normalizedSupervisorOrgName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) + } + + /// Entfernt Quotes/Whitespace aus OrganizationName damit Skip-Logik robust ist + /// (z.B. wenn Tools "ReBreak" statt ReBreak liefern). + var normalizedSupervisorOrgName: String? { + supervisorOrgName? + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } /// Ground-Truth: ist das Enrollment-Profil aktuell auf dem iPhone installiert? diff --git a/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift b/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift index fb1a360..9b84f36 100644 --- a/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift +++ b/apps/rebreak-binder-mac/Sources/Models/WizardModel.swift @@ -19,6 +19,8 @@ final class WizardModel { var configureRunning: Bool = false var configureError: String? + var showAdvancedLogs: Bool = false + var cooldownEndsAt: Date? func advance() { @@ -40,6 +42,7 @@ final class WizardModel { supervisionError = nil enrollmentError = nil configureError = nil + showAdvancedLogs = false cooldownEndsAt = nil } } diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 5dc4daa..700d5cf 100644 --- a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,15 +1,15 @@ { "images" : [ - {"idiom" : "mac", "scale" : "1x", "size" : "16x16"}, - {"idiom" : "mac", "scale" : "2x", "size" : "16x16"}, - {"idiom" : "mac", "scale" : "1x", "size" : "32x32"}, - {"idiom" : "mac", "scale" : "2x", "size" : "32x32"}, - {"idiom" : "mac", "scale" : "1x", "size" : "128x128"}, - {"idiom" : "mac", "scale" : "2x", "size" : "128x128"}, - {"idiom" : "mac", "scale" : "1x", "size" : "256x256"}, - {"idiom" : "mac", "scale" : "2x", "size" : "256x256"}, - {"idiom" : "mac", "scale" : "1x", "size" : "512x512"}, - {"idiom" : "mac", "scale" : "2x", "size" : "512x512"} + { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, + { "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, + { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, + { "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, + { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, + { "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, + { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, + { "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, + { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, + { "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..ab43b12 Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..609d38b Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..82d04ad Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..c07441d Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..609d38b Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..cb4e0f4 Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..c07441d Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..bfe24b3 Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..cb4e0f4 Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..ec9b4d7 Binary files /dev/null and b/apps/rebreak-binder-mac/Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift b/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift index 83dc49c..c3770f8 100644 --- a/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift +++ b/apps/rebreak-binder-mac/Sources/Services/DeviceDetector.swift @@ -5,15 +5,27 @@ import Foundation enum DeviceDetector { enum DetectorError: Error, LocalizedError { case ideviceinfoMissing + case cfgutilMissing case noDevice + case deviceLocked + case profileUserInteractionRequired + case profileInstallRequiresManagementTool case parseError(String) var errorDescription: String? { switch self { case .ideviceinfoMissing: return "ideviceinfo nicht gefunden — bitte `brew install libimobiledevice` ausführen." + case .cfgutilMissing: + return "cfgutil nicht gefunden — bitte Apple Configurator installieren." case .noDevice: return "Kein iPhone via USB erkannt. Kabel + Trust-Dialog am iPhone prüfen." + case .deviceLocked: + return "iPhone ist gesperrt. Bitte entsperren und USB verbunden lassen." + case .profileUserInteractionRequired: + return "iOS verlangt eine Bestätigung direkt am iPhone, um das Profil zu installieren." + case .profileInstallRequiresManagementTool: + return "Lokale Profil-Installation ist durch iOS-Policy blockiert (DMC 4020). Dieses Profil muss per MDM-Command installiert werden oder per AirDrop/User-Flow bestätigt werden." case .parseError(let msg): return "Parse-Fehler: \(msg)" } @@ -63,7 +75,13 @@ enum DeviceDetector { /// Wir betrachten das Gerät als "schon durch uns gebunden" wenn /// OrganizationName matched. Case-insensitive für Robustheit. var isOwnedByReBreak: Bool { - isSupervised && (organizationName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) + isSupervised && (normalizedOrganizationName?.localizedCaseInsensitiveCompare("ReBreak") == .orderedSame) + } + + var normalizedOrganizationName: String? { + organizationName? + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) } } @@ -83,7 +101,7 @@ enum DeviceDetector { status.isSupervised = (v.lowercased() == "true") } if let v = parseEquals(line: line, key: "OrganizationName") { - status.organizationName = v + status.organizationName = normalizeOrgName(v) } } } @@ -98,11 +116,21 @@ enum DeviceDetector { if status.isSupervised == false, let v = parseColon(line: line, key: "IsSupervised") { status.isSupervised = (v.lowercased() == "true") } + if status.organizationName == nil, + let v = parseColon(line: line, key: "OrganizationName") ?? parseColon(line: line, key: "SupervisionOrganizationName") { + status.organizationName = normalizeOrgName(v) + } } } return status } + private static func normalizeOrgName(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + } + /// Parse ` Key = Value` (cloud-config Format). private static func parseEquals(line: String, key: String) -> String? { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -157,4 +185,65 @@ enum DeviceDetector { return trimmed.split(whereSeparator: { $0 == "\t" || $0 == " " }).first.map(String.init) } } + + /// Versucht ein .mobileconfig direkt auf ein per USB verbundenes iPhone zu + /// installieren. Nutzt cfgutil und ist damit ohne AirDrop-Dialog möglich, + /// sofern Device trusted/entsperrt ist. + static func installProfileSilently(path: String) async throws { + guard let cfgutil = Paths.cfgutilPath else { + throw DetectorError.cfgutilMissing + } + let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "install-profile", path]) + if r.exitCode != 0 { + let err = r.stderr.isEmpty ? r.stdout : r.stderr + if err.localizedCaseInsensitiveContains("device is locked") { + throw DetectorError.deviceLocked + } + if err.localizedCaseInsensitiveContains("benutzerinteraktion") + || err.localizedCaseInsensitiveContains("user interaction") + || err.contains("MCInstallationErrorDomain Code: 4009") { + throw DetectorError.profileUserInteractionRequired + } + if err.contains("DMCInstallationErrorDomain") && err.contains("Code: 4020") { + throw DetectorError.profileInstallRequiresManagementTool + } + throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// Entfernt eine App per Bundle-ID via cfgutil (USB). + static func removeApp(bundleID: String) async throws { + guard let cfgutil = Paths.cfgutilPath else { + throw DetectorError.cfgutilMissing + } + let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "remove-app", bundleID]) + if r.exitCode != 0 { + let err = r.stderr.isEmpty ? r.stdout : r.stderr + throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// Entfernt alle per Identifier angegebenen Profile via cfgutil. + /// Wird für interne Test-Resets genutzt. + static func removeProfiles(identifiers: [String]) async throws { + guard let cfgutil = Paths.cfgutilPath else { + throw DetectorError.cfgutilMissing + } + for identifier in identifiers { + let r = try await ProcessRunner.run(cfgutil, arguments: ["--foreach", "remove-profile", identifier]) + if r.exitCode != 0 { + let err = r.stderr.isEmpty ? r.stdout : r.stderr + throw DetectorError.parseError(err.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + } + + /// Internal QA helper: entfernt alle Profile mit `org.rebreak.` Prefix. + /// Returnt die tatsächlich angezielten Profil-IDs. + static func removeAllReBreakProfiles() async throws -> [String] { + let profileIDs = await installedProfileIDs().filter { $0.hasPrefix("org.rebreak.") } + guard !profileIDs.isEmpty else { return [] } + try await removeProfiles(identifiers: profileIDs) + return profileIDs + } } diff --git a/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift b/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift index ecf3979..1cf8299 100644 --- a/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift +++ b/apps/rebreak-binder-mac/Sources/Services/SuperviseRunner.swift @@ -34,13 +34,14 @@ enum SuperviseRunner { static func supervise( organizationName: String = "ReBreak", force: Bool = true, + verbose: Bool = false, onLine: @escaping (String) -> Void ) async throws -> ProcessRunner.Result { guard let bin = Paths.firstExecutable(in: Paths.superviseMagicCandidates) else { throw RunnerError.binaryMissing } // -yes ist Pflicht: ohne TTY-Pipe hängt der Bestätigungs-Prompt sonst endlos. - var args: [String] = ["-v", "-yes"] + var args: [String] = verbose ? ["-v", "-yes"] : ["-yes"] if force { args.append("-force") } args.append(contentsOf: ["-org", organizationName, "supervise"]) let result = try await ProcessRunner.stream(bin, arguments: args, onLine: onLine) diff --git a/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift b/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift index 2ded2f8..b76588c 100644 --- a/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift +++ b/apps/rebreak-binder-mac/Sources/Views/ConfigureView.swift @@ -4,21 +4,59 @@ struct ConfigureView: View { @Environment(WizardModel.self) private var model @State private var task: Task? + @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("Wizard pusht 2 MDM-Commands (silent über APNs): App wird **managed**, NEFilter-Mode aktiviert. Danach Sideload des Lock-Profils per AirDrop (User-Tap am iPhone).") + 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 - logViewer + if model.showAdvancedLogs { + logViewer + } + + Button(model.showAdvancedLogs ? "Details ausblenden" : "Details anzeigen") { + model.showAdvancedLogs.toggle() + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) Spacer() @@ -41,11 +79,10 @@ struct ConfigureView: View { private var stepList: some View { VStack(alignment: .leading, spacing: 6) { - Label("Pre-Check: ist ReBreak-App auf iPhone? Managed?", systemImage: "magnifyingglass") - Label("Mode-Auswahl: Take-Management (TF-installiert) ODER Install-Push (Ad-Hoc-IPA via Manifest)", systemImage: "arrow.triangle.branch") - Label("Settings mdmSupervised=true (NEFilter-Mode)", systemImage: "shield") - Label("Post-Check: ManagedApplicationList Query — managed verified?", systemImage: "checkmark.seal") - Label("Sideload Lock-Profile per AirDrop", systemImage: "paperplane") + 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) @@ -56,14 +93,14 @@ struct ConfigureView: 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 : .orange) + .foregroundStyle(installed ? .green : .secondary) Text(installed - ? "ReBreak-App ist installiert (cfgutil) — Mode: Take-Management" - : "ReBreak-App NICHT installiert — Mode: Install-Push (Manifest)") + ? "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.orange).opacity(0.08)) + .background((installed ? Color.green : Color.blue).opacity(0.08)) .cornerRadius(6) } @@ -72,7 +109,7 @@ struct ConfigureView: View { if model.configureRunning { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Sende Commands an NanoMDM …") + Text("Automatischer Schutz-Flow läuft …") } .padding(10) .background(Color.blue.opacity(0.08)) @@ -88,7 +125,7 @@ struct ConfigureView: View { } else if !model.configureLog.isEmpty { HStack { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text("3 Commands erfolgreich enqueued. iPhone empfängt via APNs (~5–30 Sekunden).") + Text("Schutz vollständig validiert. Du kannst abschließen.") } .padding(10) .background(Color.green.opacity(0.08)) @@ -125,23 +162,17 @@ struct ConfigureView: View { .buttonStyle(.bordered) .disabled(model.configureRunning) Spacer() - if let path = sideloadProfilePath, !model.configureRunning, model.configureError == nil, !model.configureLog.isEmpty { - Button("Lock-Profile per AirDrop senden") { - sendViaAirDrop(path: path) - } - .buttonStyle(.borderedProminent) - Button("…im Finder zeigen") { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) - } - .buttonStyle(.bordered) + 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) } - if model.configureError != nil { - Button("Neu versuchen") { startConfigure() } - .buttonStyle(.bordered) - } - Button("Schutz ist aktiv → Fertig") { model.advance() } - .buttonStyle(.borderedProminent) - .disabled(model.configureRunning || model.configureLog.isEmpty || model.configureError != nil) } } @@ -186,6 +217,12 @@ struct ConfigureView: View { 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 { @@ -217,71 +254,116 @@ struct ConfigureView: View { // bleibt nach Ack auf true — daher zählen wir command_results. let pushStartTime = Date() - // Mode-Auswahl: wenn App schon installed → Take-Management, - // sonst → Install-Push via Manifest. - let appAlreadyInstalled = model.device?.installedAppBundleIDs.contains("org.rebreak.app") == true - let modeLabel = appAlreadyInstalled - ? "Take-Management (App schon installiert, nur managed-state setzen)" - : "Install-Push via Manifest (App nicht installiert, Ad-Hoc-IPA pushen)" - model.configureLog.append("→ Mode: \(modeLabel)") - - model.configureLog.append("→ [1/2] MDM-Push InstallApplication …") - let r1: String + // 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 { - r1 = try await MDMClient.takeManagement(udid: udid) + 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 { - r1 = try await MDMClient.installApp(udid: udid) + model.configureLog.append("→ ReBreak-App nicht vorhanden, starte frischen Install-Push.") } - model.configureLog.append("✓ enqueued: \(r1.prefix(80))") - model.configureLog.append("→ [2/2] MDM-Push Settings mdmSupervised=true …") - let r2 = try await MDMClient.setSupervisedMode(udid: udid) - model.configureLog.append("✓ enqueued: \(r2.prefix(80))") + 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("") - model.configureLog.append("Beide MDM-Pushes enqueued. Warte 30s und re-check ob iPhone sie acked …") + 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 + } - // 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 { model.configureLog.append("✓ iPhone hat ge-acked (\(lastAckAfter!.formatted(date: .omitted, time: .standard))).") - // Post-Check 1: cfgutil refresh — ist App jetzt installiert? let appsAfter = await DeviceDetector.installedAppBundleIDs() let isAppInstalled = appsAfter.contains("org.rebreak.app") model.configureLog.append(isAppInstalled - ? "✓ ReBreak-App jetzt auf iPhone (cfgutil)." - : "⚠ ReBreak-App noch nicht auf iPhone — iPhone lädt evtl. noch (IPA = 19.6MB).") + ? "✓ ReBreak-App ist auf dem iPhone." + : "⚠ ReBreak-App noch nicht sichtbar (Versuch \(attempt)).") - // Post-Check 2: ManagedApplicationList-Query — ist App managed? - model.configureLog.append("→ Post-Check: ManagedApplicationList query …") - do { - if let isManaged = try await MDMClient.checkAppIsManaged(udid: udid) { - model.configureLog.append(isManaged - ? "✓ ReBreak ist MANAGED. App nicht löschbar durch User." - : "⚠ ReBreak ist installiert aber NICHT managed.") - model.device?.isManaged = isManaged - } else { - model.configureLog.append("⚠ iPhone hat Managed-Query nicht (rechtzeitig) ge-acked.") - } - } catch { - model.configureLog.append("⚠ Post-Check fehlgeschlagen: \(error.localizedDescription)") + 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 } - } else { - model.configureLog.append("✗ Kein neuer Ack nach 30s. Push-Zeitstempel: \(pushStartTime.formatted(date: .omitted, time: .standard)), letzter Ack: \(lastAckAfter?.formatted(date: .omitted, time: .standard) ?? "nie").") - throw NSError(domain: "Binder", code: 2, userInfo: [NSLocalizedDescriptionKey: - "iPhone hat 30s lang keine MDM-Commands abgeholt — MDM-Channel tot. Step 4 wiederholen."]) + 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("→ Sideload-Step: Lock-Profile per AirDrop ans iPhone schicken …") - model.configureLog.append(" Datei: \(sideloadProfilePath ?? "(nicht gefunden)")") - model.configureLog.append(" Am iPhone: Profil-Dialog akzeptieren → Settings → Profil installieren") - model.device?.isFilterActive = true // wird's nach sideload sein + 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 @@ -289,4 +371,49 @@ struct ConfigureView: View { } } } + + private func waitForLockProfileInstalled(maxChecks: Int = 40, intervalSeconds: UInt64 = 3) async -> Bool { + for _ in 0.. 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() + } + } } diff --git a/apps/rebreak-binder-mac/Sources/Views/ContentView.swift b/apps/rebreak-binder-mac/Sources/Views/ContentView.swift index dd0fc45..f8aa709 100644 --- a/apps/rebreak-binder-mac/Sources/Views/ContentView.swift +++ b/apps/rebreak-binder-mac/Sources/Views/ContentView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ContentView: View { @@ -5,13 +6,17 @@ struct ContentView: View { var body: some View { VStack(spacing: 0) { - // Header mit Step-Indicator VStack(spacing: 8) { HStack { - Image(systemName: "shield.lefthalf.filled") - .foregroundStyle(.tint) - Text("ReBreak Binder") - .font(.headline) + appBadge + + VStack(alignment: .leading, spacing: 1) { + Text("ReBreak Binder") + .font(.headline) + Text("macOS supervision tool") + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() if model.step != .done { Text("Schritt \(model.step.stepNumber) von \(WizardStep.total)") @@ -42,4 +47,39 @@ struct ContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + @ViewBuilder + private var appBadge: some View { + if let icon = resolvedAppIcon { + Image(nsImage: icon) + .resizable() + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(.white.opacity(0.25), lineWidth: 1) + ) + } else { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.accentColor.opacity(0.15)) + Image(systemName: "shield.lefthalf.filled") + .foregroundStyle(.tint) + } + .frame(width: 28, height: 28) + } + } + + private var resolvedAppIcon: NSImage? { + if let icon = NSApplication.shared.applicationIconImage, + icon.size.width > 2, + icon.size.height > 2 { + return icon + } + let bundleIcon = NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) + if bundleIcon.size.width > 2, bundleIcon.size.height > 2 { + return bundleIcon + } + return nil + } } diff --git a/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift b/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift index f3ecd88..13c110c 100644 --- a/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift +++ b/apps/rebreak-binder-mac/Sources/Views/EnrollView.swift @@ -1,19 +1,105 @@ 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? + @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("Jetzt installierst du das **minimale** MDM-Enrollment-Profile, damit dein iPhone mit unserem NanoMDM-Server (mdm.rebreak.org) sprechen kann. Das Profile bringt **keine Restrictions** — nur den MDM-Channel. Restrictions kommen später per Sideload-Lock.") + 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() @@ -21,7 +107,19 @@ struct EnrollView: View { navigationBar } .padding(40) - .onAppear { downloadProfile() } + .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 { @@ -29,14 +127,14 @@ struct EnrollView: View { Image(systemName: "doc.badge.gearshape") .font(.system(size: 30)) .foregroundStyle(.tint) - Text("MDM-Enrollment") + Text("Verbindung einrichten") .font(.title).bold() } } private var instructions: some View { VStack(alignment: .leading, spacing: 14) { - stepRow(number: 1, text: "Profile wird automatisch vom Server runtergeladen.") + 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") @@ -46,10 +144,18 @@ struct EnrollView: View { .padding(.leading, 36) } - stepRow(number: 2, text: "Klick „Per AirDrop senden\" → wähle dein iPhone im Sheet.") - stepRow(number: 3, text: "Am iPhone: AirDrop-Dialog akzeptieren → Settings öffnet sich automatisch.") - stepRow(number: 4, text: "Settings → „Installieren\" tappen → 6-stelligen Geräte-Code eingeben → „Installieren\" bestätigen.") - stepRow(number: 5, text: "Zurück hier klick auf „Enrollment fertig → Weiter\".") + 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.") } } @@ -71,27 +177,27 @@ struct EnrollView: View { Button("Zurück") { model.goTo(.supervise) } .buttonStyle(.bordered) Spacer() - if let path = localPath { - Button("Per AirDrop senden") { - sendViaAirDrop(path: path) - } - .buttonStyle(.borderedProminent) + if enrollmentReady { + Text("Enrollment bestätigt. Weiterleitung läuft automatisch …") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Text("Bitte kurz warten …") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } - Button("…im Finder zeigen") { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) - } - .buttonStyle(.bordered) - } - Button("Enrollment fertig → Weiter") { - model.device?.isEnrolled = true - model.advance() - } - .buttonStyle(.bordered) + 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 { @@ -107,20 +213,124 @@ struct EnrollView: View { 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 sendViaAirDrop(path: String) { - let url = URL(fileURLWithPath: path) - guard let service = NSSharingService(named: .sendViaAirDrop), service.canPerform(withItems: [url]) else { - NSWorkspace.shared.activateFileViewerSelecting([url]) - return + 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) } - service.perform(withItems: [url]) } } diff --git a/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift b/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift index a114043..8447623 100644 --- a/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift +++ b/apps/rebreak-binder-mac/Sources/Views/SuperviseView.swift @@ -9,12 +9,20 @@ struct SuperviseView: View { VStack(alignment: .leading, spacing: 16) { header - Text("Wir schreiben jetzt die Supervision-Plist auf dein iPhone und starten es neu. Das dauert ~60 Sekunden. **Trenne das USB-Kabel nicht.**") + Text("Wir schreiben jetzt nur die Supervision-Metadaten auf dein iPhone und starten es neu. Apps, Daten und Logins bleiben erhalten. Das dauert ~60 Sekunden. **Trenne das USB-Kabel nicht.**") .foregroundStyle(.secondary) statusBox - logViewer + if model.showAdvancedLogs { + logViewer + } + + Button(model.showAdvancedLogs ? "Details ausblenden" : "Details anzeigen") { + model.showAdvancedLogs.toggle() + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) Spacer() @@ -117,11 +125,20 @@ struct SuperviseView: View { task?.cancel() task = Task { @MainActor in do { - _ = try await SuperviseRunner.supervise(organizationName: "ReBreak", force: true) { line in + _ = try await SuperviseRunner.supervise( + organizationName: "ReBreak", + force: true, + verbose: model.showAdvancedLogs + ) { line in model.supervisionLog.append(line) } model.supervisionRunning = false model.device?.isSupervised = true + model.device?.supervisorOrgName = "ReBreak" + // Nach re-supervise ist der MDM-Channel oft weg; Enroll-Step soll + // deshalb nicht fälschlich übersprungen werden. + model.device?.isEnrolled = false + model.device?.enrollmentStatus = nil } catch { model.supervisionError = error.localizedDescription model.supervisionRunning = false diff --git a/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift b/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift index ca8dcfa..db047e9 100644 --- a/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift +++ b/apps/rebreak-binder-mac/Sources/Views/WelcomeView.swift @@ -1,11 +1,34 @@ 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? + @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) { @@ -47,12 +70,71 @@ struct WelcomeView: View { .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" @@ -197,4 +279,83 @@ struct WelcomeView: View { } } } + + 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 + } + } + } + } } diff --git a/apps/rebreak-native/.gitignore b/apps/rebreak-native/.gitignore index 4bd5ccd..6a15af4 100644 --- a/apps/rebreak-native/.gitignore +++ b/apps/rebreak-native/.gitignore @@ -43,3 +43,6 @@ yarn-error.* # Storybook storybook-static/ +android/local.properties +android/key.properties +apps/rebreak-native/tmp/ diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index 739206d..fba86e8 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 27 / versionCode 18) — 2026-05-30\n\nPush-Notifications für Chat: Du erhältst jetzt Pushes bei neuen Direkt-Nachrichten und Raum-Nachrichten. Abschaltbar in den Einstellungen.\n ## v0.3.13 (Build 26 / versionCode 16) — 2026-05-30\n\nneue push für chat\n Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) Versioning: `version` follows SemVer, `versionCode` is monotonically increasing. diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md index 68bb06b..3a244a7 100644 --- a/apps/rebreak-native/NEXT_RELEASE.md +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -1 +1 @@ -Push-Notifications für Chat: Du erhältst jetzt Pushes bei neuen Direkt-Nachrichten und Raum-Nachrichten. Abschaltbar in den Einstellungen. +Chat-DM: Lange auf Nachricht drücken öffnet jetzt das native Aktions-Menü (Antworten, Liken, Kopieren). Likes erscheinen Insta-Style als Herz unter der Nachricht. diff --git a/apps/rebreak-native/README.md b/apps/rebreak-native/README.md index 4d5c2da..8beec7c 100644 --- a/apps/rebreak-native/README.md +++ b/apps/rebreak-native/README.md @@ -72,6 +72,57 @@ apps/rebreak-native/ └── assets/ # Icons, Splashscreens, Fonts ``` +## Dev-Variant (Hot-Reload parallel zur MDM-App) + +Die MDM-managed Dist-App (`org.rebreak.app`) ist non-removable auf dem iPhone Air. +Der Dev-Build nutzt eine separate Bundle-ID (`org.rebreak.app.dev`) und kann parallel installiert werden. + +### Erstmaliges Setup (nur einmal nötig) + +```bash +# 1. Dev-Build starten — schlägt beim ersten Mal wegen Signing-Bundle fehl +REBREAK_DEV=1 ./dev-build.sh --clean + +# Wenn Xcode "No profiles for 'org.rebreak.app.dev'" meldet: +# a) open ios/ReBreak.xcworkspace +# b) Targets → ReBreak → Signing & Capabilities +# c) Team auf "84BQ7MTFYK" setzen — Xcode registriert Bundle-ID automatisch +# d) Xcode schliessen, nochmal ausführen: +REBREAK_DEV=1 ./dev-build.sh --clean +``` + +### Täglicher Workflow + +```bash +# Vollbuild (nach native-code Änderungen oder erstem Mal): +./dev-build.sh + +# Nur Metro (wenn Dev-App schon auf iPhone, reine JS-Änderungen): +./dev-build.sh --metro-only + +# Nuclear clean + rebuild (nach Plugin/Pod-Änderungen): +./dev-build.sh --clean +``` + +### Wichtige Unterschiede Dev vs. Dist + +| | Dev (`org.rebreak.app.dev`) | Dist (`org.rebreak.app`) | +|---|---|---| +| App-Name | ReBreak Dev | ReBreak | +| Splash-Color | #1e3a5f (dunkles Blau) | #0f172a (schwarz) | +| URL-Scheme | `rebreak-dev://` | `rebreak://` | +| App-Group | `group.org.rebreak.app` (geteilt) | `group.org.rebreak.app` | +| Hot-Reload | via Metro | nein | +| MDM-managed | nein | ja (non-removable) | + +App-Group ist bewusst geteilt — Dev-Build kann blocklist.bin lesen und +Sideload-Profile-Verhalten testen ohne MDM-Push. + +### Prod-Build bleibt unverändert + +`deploy-adhoc.sh` und `deploy-tf.sh` setzen `REBREAK_DEV` nicht +→ landen automatisch auf `org.rebreak.app`. + ## Wichtige Konfiguration | Datei | Zweck | @@ -107,6 +158,75 @@ Wrapped: - **Alte Capacitor-App** bleibt deployed bis RN-App im Store ist. - **Kein Auto-Commit** — User entscheidet wann committet wird. +## Release-Pipeline + +### Multi-Target Deploy (`deploy-app.sh`) + +Ein einziges Script baut und deployed alle drei Release-Targets: + +```bash +# Default: Alle drei Targets (MDM + TestFlight + Android) +./deploy-app.sh --bump + +# Nur spezifische Targets +./deploy-app.sh --mdm-only # Nur MDM (Ad-Hoc iOS) +./deploy-app.sh --tf-only # Nur TestFlight +./deploy-app.sh --android-only # Nur Android (EAS → Play-Console) + +# Targets selektiv überspringen +./deploy-app.sh --skip-mdm --bump # TestFlight + Android +./deploy-app.sh --skip-android # Nur iOS (MDM + TF) + +# Version-Bumping +./deploy-app.sh --bump # iOS buildNumber++, Android versionCode++ +./deploy-app.sh --version 0.4.0 # Explizite SemVer +./deploy-app.sh --android-version-code 15 # Override Android versionCode + +# Dry-Run (alles simulieren) +./deploy-app.sh --dry-run --bump +``` + +#### Was passiert pro Target + +| Target | Pipeline | Output | +|---|---|---| +| **MDM** | `deploy-adhoc.sh` → xcodebuild → scp upload | MDM-Push via NanoMDM (systemd-watcher) | +| **TestFlight** | `deploy-tf.sh` → altool upload | ASC → Internal Testing (~5-15min) | +| **Android** | EAS Cloud-Build → Play-Console | Internal-Track (~10-30min Processing) | + +#### Android-Vorbereitung (einmalig) + +Android-Submit benötigt `serviceAccountKeyPath` in `eas.json`: + +1. Google-Cloud-Service-Account erstellen (Play-Console-Zugriff) +2. JSON-Key downloaden (z.B. `~/secrets/rebreak-play-service-account.json`) +3. In `eas.json` eintragen: + ```json + "submit": { + "production": { + "android": { + "serviceAccountKeyPath": "~/secrets/rebreak-play-service-account.json", + "track": "internal" + } + } + } + ``` +4. **NIEMALS committen** (liegt in `.gitignore`) + +Falls noch nicht konfiguriert → Script bricht mit klarer Fehlermeldung ab. + +#### Changelog + +Changelog-Updates erfolgen bei `--bump` automatisch für iOS (via `deploy-adhoc.sh` intern) und Android (via `deploy-app.sh`). + +### Alte Scripts (weiterhin nutzbar) + +- `deploy-adhoc.sh` — MDM (Ad-Hoc iOS) standalone +- `deploy-tf.sh` — TestFlight standalone (wiederverwendet xcarchive) +- `eas-release.sh` — EAS Cloud-Build (manueller Wrapper, KEIN Version-Bumping) + +`deploy-app.sh` ist die empfohlene All-in-One-Lösung. + ## Phasen-Tracker Siehe Migration-Plan für Details: [`apps/rebreak/docs/react-native-migration.md`](../rebreak/docs/react-native-migration.md) diff --git a/apps/rebreak-native/SCRIPTS.md b/apps/rebreak-native/SCRIPTS.md new file mode 100644 index 0000000..cb1f405 --- /dev/null +++ b/apps/rebreak-native/SCRIPTS.md @@ -0,0 +1,223 @@ +# ReBreak Native — Deploy & Dev Scripts + +## Quick Start + +### Development +```bash +# iOS Dev (Metro + Xcode): +./dev.sh ios + +# iOS Dev auf physischem iPhone (USB): +./dev.sh ios --device + +# iOS Dev auf iPhone via WiFi: +./dev.sh ios --wifi + +# Android Dev: +./dev.sh android + +# Nur Metro starten: +./dev.sh metro + +# iOS Clean + Rebuild: +./dev.sh clean --build + +# Release-Build auf iPhone installieren: +./dev.sh install ios +``` + +### Deployment +```bash +# Full Release (alle Plattformen): +./deploy.sh all --bump + +# Nur iOS TestFlight: +./deploy.sh testflight --bump + +# Nur iOS MDM (Ad-Hoc): +./deploy.sh mdm --bump + +# Nur Android: +./deploy.sh android --bump + +# Dry-Run zum Testen: +./deploy.sh all --bump --dry-run +``` + +--- + +## `./deploy.sh` — Multi-Platform Release + +### Subcommands +- `all` (default) — Alle drei Targets (testflight + mdm + android) +- `testflight` — iOS TestFlight via App Store Connect +- `mdm` — iOS Ad-Hoc IPA + scp Upload zu MDM-Server +- `android` — Android APK/AAB via Gradle + Play Console + +### Flags +- `--bump` — Bump Build-Number (iOS + Android versionCode) +- `--version X.Y.Z` — Explizite Version setzen +- `--build N` — Explizite iOS Build-Nummer +- `--android-version-code N` — Override Android versionCode +- `--skip-clean` — clean-ios.sh überspringen (iOS) +- `--skip-validate` — altool --validate-app überspringen (TF) +- `--skip-submit` — Play-Console-Submit überspringen (Android) +- `--dry-run` — Alles simulieren, nichts ausführen + +### Credentials + +**iOS TestFlight:** +- `APPLE_APP_SPECIFIC_PASSWORD` (oder) +- `ASC_API_KEY_PATH` + `ASC_API_KEY_ID` + `ASC_API_KEY_ISSUER` + +**iOS MDM:** +- SSH-Access zu `rebreak-mdm` Server (VPN muss laufen) + +**Android:** +- ⚠️ **NICHT READY** — siehe Setup unten + +--- + +## `./dev.sh` — Development Tooling + +### Subcommands +- `ios` (default) — iOS Dev (Metro + Xcode/Simulator/Device) +- `android` — Android Dev (Metro + Gradle build + install) +- `metro` — Nur Metro starten +- `clean` — iOS Nuclear Clean (Pods, DerivedData, Archives) +- `install ios` — Release-Build auf iPhone USB installieren +- `install android` — Debug-APK auf Android Device installieren + +### Flags (ios) +- `--device` — Build auf physisches iPhone via USB +- `--simulator` — Build auf iOS Simulator (default) +- `--xcode` — Nur Xcode öffnen (manueller Build) +- `--wifi` — Metro mit --host lan (für WiFi-Dev auf iPhone) + +### Flags (android) +- `--no-build` — Skip Gradle build, nur install last APK +- `--no-launch` — Install but don't auto-launch + +### Flags (metro) +- `--keep` — Cache behalten (kein --clear) + +### Flags (clean) +- `--build` — + iOS build am Ende +- `--xcode` — + Xcode öffnen am Ende + +--- + +## `./eas-release.sh` — EAS Cloud-Build + +**SEPARATER WORKFLOW** — Cloud-Build via Expo Application Services (kein lokaler Build). + +```bash +# iOS only (build + TestFlight): +./eas-release.sh + +# Android AAB + Play Console Internal: +./eas-release.sh --android + +# iOS + Android parallel: +./eas-release.sh --both + +# Nur Build, kein Submit: +./eas-release.sh --build-only +``` + +**Voraussetzungen:** +- `eas login` einmalig durchgeführt +- Android: `serviceAccountKeyPath` in eas.json gesetzt +- iOS: Apple-Connect-Login beim ersten Run + +**Hinweis:** Bleibt unangetastet — wird NICHT in `deploy.sh` integriert, da völlig anderer Workflow (Cloud vs. lokal). + +--- + +## ⚠️ Android Deployment Setup — NOCH NICHT READY + +### Fehlende Credentials + +**1. Release Keystore:** +```bash +# Keystore generieren: +keytool -genkey -v -keystore ~/rebreak-release.keystore \ + -alias rebreak -keyalg RSA -keysize 2048 -validity 10000 + +# Keystore nach android/app/ kopieren (nach prebuild): +npx expo prebuild --platform android --no-install +cp ~/rebreak-release.keystore android/app/ +``` + +**2. Signing Config:** +```bash +# key.properties erstellen (NIEMALS committen): +cat > android/key.properties << EOF +storePassword= +keyPassword= +keyAlias=rebreak +storeFile=rebreak-release.keystore +EOF + +# .gitignore prüfen (sollte bereits vorhanden sein): +# android/key.properties +# android/app/*.keystore +``` + +**3. Play Console Service-Account JSON (für Submit):** +```bash +# 1. Google Cloud Console → Service Accounts → Create → JSON-Key +# 2. Play Console → Setup → API-Access → Service-Account linken +# 3. Permissions: "Releases" (Edit + Read) +# 4. JSON-Key ablegen: +mkdir -p ~/secrets +mv ~/Downloads/rebreak-play-*.json ~/secrets/rebreak-play-service-account.json + +# Optional: ENV-Variable setzen (in ~/.zshrc): +export PLAY_SERVICE_ACCOUNT_JSON=~/secrets/rebreak-play-service-account.json +``` + +**URLs:** +- Google Cloud Console: https://console.cloud.google.com/iam-admin/serviceaccounts +- Play Console API Access: https://play.google.com/console → Setup → API Access + +### Status-Check + +```bash +# Prüfen ob alles vorhanden: +ls -la android/key.properties +ls -la android/app/*.keystore +ls -la ~/secrets/rebreak-play-service-account.json + +# Wenn alle drei Dateien existieren: +./deploy.sh android --bump +``` + +**Ohne Service-Account-JSON:** +```bash +# Build-only (kein Submit zu Play Store): +./deploy.sh android --bump --skip-submit + +# AAB dann manuell uploaden: +# Play Console → Releases → Internal Testing → Create Release +# Upload: android/app/build/outputs/bundle/release/app-release.aab +``` + +--- + +## Changelog + +**2026-05-30 — Script-Konsolidierung:** +- ✓ `deploy.sh` ersetzt `deploy-app.sh`, `deploy-adhoc.sh`, `deploy-tf.sh` +- ✓ `dev.sh` ersetzt `dev-build.sh`, `dev-ios.sh`, `dev-iphone.sh`, `install-ios.sh`, `install-android.sh`, `clean-ios.sh`, `metro.sh` +- ✓ `eas-release.sh` bleibt separat (Cloud-Build-Workflow) +- ✓ Coloured brew-style output (✓/✗, Sections) +- ✓ `--help` für alle Scripts +- ⚠️ Android-Deploy: Credentials fehlen (siehe Setup oben) + +**Legacy Scripts gelöscht:** +- `deploy-app.sh`, `deploy-adhoc.sh`, `deploy-tf.sh` +- `dev-build.sh`, `dev-ios.sh`, `dev-iphone.sh` +- `install-ios.sh`, `install-android.sh` +- `clean-ios.sh`, `metro.sh` +- `DEPLOY_APP_USAGE.md` (Info in `--help` integriert) diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index dcaf26f..687f1d7 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -1,26 +1,39 @@ import { ExpoConfig, ConfigContext } from "expo/config"; +import pkg from "./package.json"; + +// ─── Dev-Variant-Flag ───────────────────────────────────────────────────────── +// REBREAK_DEV=1 → separates Bundle-org.rebreak.app.dev, App-Name "ReBreak Dev". +// Ermöglicht parallele Installation neben der MDM-managed Dist-App. +// Produktions-Builds (deploy-adhoc.sh, deploy-tf.sh, EAS) setzen REBREAK_DEV +// NICHT → landen automatisch auf dem Prod-Bundle. +// +// Verwendung: REBREAK_DEV=1 ./dev-build.sh +const IS_DEV = process.env.REBREAK_DEV === "1"; +const PROD_BUNDLE = "org.rebreak.app"; +const DEV_BUNDLE = "org.rebreak.app.dev"; +const MAIN_BUNDLE = IS_DEV ? DEV_BUNDLE : PROD_BUNDLE; export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, - name: "ReBreak", + name: IS_DEV ? "ReBreak Dev" : "ReBreak", slug: "rebreak", - version: "0.3.6", + version: pkg.version, orientation: "portrait", icon: "./assets/icon.png", - scheme: "rebreak", + scheme: IS_DEV ? "rebreak-dev" : "rebreak", userInterfaceStyle: "automatic", newArchEnabled: true, splash: { image: "./assets/icon.png", resizeMode: "contain", - backgroundColor: "#0f172a", + backgroundColor: IS_DEV ? "#1e3a5f" : "#0f172a", }, ios: { supportsTablet: true, - bundleIdentifier: "org.rebreak.app", - buildNumber: "15", + bundleIdentifier: MAIN_BUNDLE, + buildNumber: "27", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // com.apple.developer.applesignin-Entitlement nicht in die .entitlements. @@ -43,7 +56,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 11, + versionCode: 18, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem @@ -115,14 +128,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { appExtensions: [ { - // Layer 1 (NEU, Default) — Packet-Tunnel-DNS-Filter. + // Layer 1 (Unsupervised-Pfad) — Packet-Tunnel-DNS-Filter. // Bundle-ID + Entitlements müssen exakt zu // plugins/with-rebreak-protection-ios.js (PT_BUNDLE_SUFFIX) // und modules/rebreak-protection/ios/RebreakPacketTunnelExtension/ // passen, sonst kippt der EAS-Build mit - // "No profiles for 'org.rebreak.app.PacketTunnelExtension'". + // "No profiles for '...PacketTunnelExtension'". + // IS_DEV: → org.rebreak.app.dev.PacketTunnelExtension targetName: "RebreakPacketTunnelExtension", - bundleIdentifier: "org.rebreak.app.PacketTunnelExtension", + bundleIdentifier: `${MAIN_BUNDLE}.PacketTunnelExtension`, entitlements: { "com.apple.developer.networking.networkextension": [ "packet-tunnel-provider", @@ -132,6 +146,25 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ], }, }, + { + // Layer 1 (Supervised-Pfad) — klassisches NEFilterDataProvider. + // Wird auf MDM-supervised Geräten statt PacketTunnel aktiviert, + // damit der User keinen VPN-Toggle in iOS-Settings hat. Bundle-ID + // + Entitlements müssen zu plugins/with-rebreak-protection-ios.js + // (CF_BUNDLE_SUFFIX) und modules/rebreak-protection/ios/ + // RebreakContentFilter/ passen. + // IS_DEV: → org.rebreak.app.dev.ContentFilterExtension + targetName: "RebreakContentFilter", + bundleIdentifier: `${MAIN_BUNDLE}.ContentFilterExtension`, + entitlements: { + "com.apple.developer.networking.networkextension": [ + "content-filter-provider", + ], + "com.apple.security.application-groups": [ + "group.org.rebreak.app", + ], + }, + }, ], }, }, diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx index 7781040..d8bab33 100644 --- a/apps/rebreak-native/app/(app)/_layout.tsx +++ b/apps/rebreak-native/app/(app)/_layout.tsx @@ -7,6 +7,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuthStore } from '../../stores/auth'; import { useNotificationStore } from '../../stores/notifications'; import { useMailConsentStore } from '../../stores/mailConsent'; +import { useCommunityStore } from '../../stores/community'; import { useColors } from '../../lib/theme'; import { NativeTabs } from '../../components/NativeTabs'; import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet'; @@ -30,6 +31,7 @@ export default function AppLayout() { const startRealtime = useNotificationStore((s) => s.startRealtime); const stopRealtime = useNotificationStore((s) => s.stopRealtime); const resetNotifications = useNotificationStore((s) => s.reset); + const composeInputFocused = useCommunityStore((s) => s.composeInputFocused); const { visible: consentVisible, connections: consentConnections, show: showConsent, hide: hideConsent, markConsented } = useMailConsentStore(); const rearmInFlightRef = useRef(false); const bypassNotifiedRef = useRef(false); @@ -85,6 +87,7 @@ export default function AppLayout() { // SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier // nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist. const [tabIconsReady, setTabIconsReady] = useState(Platform.OS !== 'android'); + const hiddenTabBar = useCallback(() => null, []); useEffect(() => { if (Platform.OS === 'android' && !tabIconsReady) { preloadTabIcons().then(() => setTabIconsReady(true)); @@ -259,6 +262,7 @@ export default function AppLayout() { { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); - // Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist. + // Auto-Sync wenn URL-Filter oder NEFilter (MDM-Mode) beim Mount aktiv ist. + // Im MDM-Mode läuft NEFilter via System-Profil — urlFilterActive ist false (kein VPN), + // aber nefilterActive=true. Sync muss auch in diesem Fall laufen. const syncedOnceRef = useRef(false); useEffect(() => { - if (!urlFilterActive) return; + if (!urlFilterActive && !nefilterActive) return; if (syncedOnceRef.current) return; syncedOnceRef.current = true; syncBlocklist().then((res) => { console.log('[blocker] auto-sync on mount:', res); if (res.ok) refresh(); }); - }, [urlFilterActive, syncBlocklist, refresh]); + }, [urlFilterActive, nefilterActive, syncBlocklist, refresh]); // Layer 2 / VIP: webContent-Domain-Liste IMMER beim Mount syncen — ungated, // da Layer 2 an Family Controls hängt, nicht am URL-Filter. @@ -274,9 +272,14 @@ export default function BlockerScreen() { }} showsVerticalScrollIndicator={false} > - {/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */} + {/* Locked-In Mode (FC / NEFilter aktiv) → NUR Schutz-Status + Cooldown-Pfad */} {lockedIn ? ( - + ) : ( setDetailsOpen(false)} onRequestDeactivation={fromDetailsToExplainer} onTalkToLyra={deflectToLyra} diff --git a/apps/rebreak-native/app/(app)/chat.tsx b/apps/rebreak-native/app/(app)/chat.tsx index b003632..a085d08 100644 --- a/apps/rebreak-native/app/(app)/chat.tsx +++ b/apps/rebreak-native/app/(app)/chat.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useState, useCallback } from 'react'; import { View, Text, @@ -133,7 +133,6 @@ export default function ChatScreen() { - {/* Search header */} @@ -144,7 +143,6 @@ export default function ChatScreen() { placeholder={t('chat.search_placeholder')} placeholderTextColor={colors.textMuted} returnKeyType="search" - clearButtonMode="never" autoCorrect={false} autoCapitalize="none" /> @@ -235,23 +233,6 @@ function makeStyles(colors: ReturnType) { borderBottomColor: colors.border, minHeight: 68, }, - dmAvatar: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: colors.surfaceElevated, - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - marginRight: 12, - flexShrink: 0, - }, - dmAvatarImg: { width: 48, height: 48 }, - dmAvatarInitials: { - fontSize: 15, - fontFamily: 'Nunito_700Bold', - color: colors.textMuted, - }, dmInfo: { flex: 1, minWidth: 0 }, dmHeaderRow: { flexDirection: 'row', @@ -288,53 +269,5 @@ function makeStyles(colors: ReturnType) { fontFamily: 'Nunito_700Bold', color: '#fff', }, - // Kept for v1.1 Groups comeback — tab styles no longer rendered - tabs: { - flexDirection: 'row', - marginTop: 12, - backgroundColor: colors.surfaceElevated, - borderRadius: 10, - padding: 3, - }, - tab: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 8, - }, - tabActive: { - backgroundColor: colors.surface, - shadowColor: '#000', - shadowOpacity: 0.05, - shadowRadius: 2, - shadowOffset: { width: 0, height: 1 }, - }, - tabText: { - fontSize: 12, - fontFamily: 'Nunito_600SemiBold', - color: colors.textMuted, - marginLeft: 5, - }, - tabTextActive: { - color: colors.brandOrange, - fontFamily: 'Nunito_700Bold', - }, - tabBadge: { - minWidth: 16, - height: 16, - borderRadius: 8, - backgroundColor: colors.brandOrange, - paddingHorizontal: 4, - alignItems: 'center', - justifyContent: 'center', - marginLeft: 5, - }, - tabBadgeText: { - fontSize: 9, - fontFamily: 'Nunito_700Bold', - color: '#fff', - }, }); } diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index d6cf09c..68a37e7 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -1,27 +1,35 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, + TextInput, FlatList, TouchableOpacity, Platform, + Alert, ActivityIndicator, StyleSheet, - KeyboardAvoidingView, + Keyboard, + type FlatList as FlatListType, } from 'react-native'; +import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { Image } from 'expo-image'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import * as ImagePicker from 'expo-image-picker'; +// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 +import * as FileSystem from 'expo-file-system/legacy'; import { apiFetch } from '../lib/api'; import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble'; -import { ChatInput, type SendPayload } from '../components/chat/ChatInput'; import { DmChatBackground } from '../components/chat/DmChatBackground'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; import { useThemeStore } from '../stores/theme'; import { useAuthStore } from '../stores/auth'; +import { supabase } from '../lib/supabase'; import { UserAvatar } from '../components/UserAvatar'; import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus'; @@ -55,6 +63,7 @@ export default function DmScreen() { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); + const isAndroid = Platform.OS === 'android'; const colors = useColors(); const styles = makeStyles(colors); const queryClient = useQueryClient(); @@ -65,6 +74,8 @@ export default function DmScreen() { const { userId } = useLocalSearchParams<{ userId: string }>(); + const flatListRef = useRef>(null); + const isNearBottomRef = useRef(true); const [messages, setMessages] = useState([]); const [partner, setPartner] = useState(null); const partnerRef = useRef(null); @@ -72,6 +83,11 @@ export default function DmScreen() { null, ); const [sending, setSending] = useState(false); + const [inputText, setInputText] = useState(''); + const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null); + const [uploading, setUploading] = useState(false); + const [keyboardVisible, setKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); // Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse) useEffect(() => { @@ -81,6 +97,33 @@ export default function DmScreen() { setReplyTo(null); }, [userId]); + // Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const show = Keyboard.addListener(showEvent, (e) => { + setKeyboardHeight(e.endCoordinates.height); + setKeyboardVisible(true); + requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); + }); + const hide = Keyboard.addListener(hideEvent, () => { + setKeyboardHeight(0); + setKeyboardVisible(false); + setTimeout(() => flatListRef.current?.scrollToEnd({ animated: false }), 50); + }); + return () => { show.remove(); hide.remove(); }; + }, []); + + // Wenn User zurücknavigiert, soll die Conversation-Liste sofort neu laden + // (unread-Badge soll verschwinden — Backend hat bereits markDmsAsRead beim GET aufgerufen) + useFocusEffect( + useCallback(() => { + return () => { + queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); + }; + }, [queryClient]), + ); + // Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug) const { isLoading, isFetching } = useQuery({ queryKey: ['dm-history', userId], @@ -117,6 +160,7 @@ export default function DmScreen() { readAt: m.readAt, })); setMessages(msgs); + requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); return data; } catch (err: any) { console.error('[dm] history fetch failed:', err?.message ?? err); @@ -128,6 +172,14 @@ export default function DmScreen() { gcTime: 0, }); + // Neue Nachricht (incoming Realtime oder outgoing send) — nur scrollen wenn nahe unten + useEffect(() => { + if (messages.length === 0) return; + if (isNearBottomRef.current) { + requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: true })); + } + }, [messages.length]); + // Realtime: neue DMs vom Partner const onDmInsert = useCallback( (row: any) => { @@ -160,15 +212,71 @@ export default function DmScreen() { ); useDmRealtime(userId, onDmInsert, !!myUserId); - const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); + async function pickImage() { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body')); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.8, + }); + if (!result.canceled && result.assets[0]?.uri) { + const a = result.assets[0]; + setAttachment({ uri: a.uri, name: a.fileName ?? `image-${Date.now()}.jpg` }); + } + } - async function handleSend(payload: SendPayload) { - if (sending) return; + async function uploadAttachment(): Promise<{ url: string; type: string; name: string } | null> { + if (!attachment) return null; + try { + setUploading(true); + const ext = attachment.name.split('.').pop() || 'jpg'; + const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; + const base64 = await FileSystem.readAsStringAsync(attachment.uri, { + encoding: FileSystem.EncodingType.Base64, + }); + const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary'); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, { + cacheControl: '3600', + upsert: false, + contentType: 'image/jpeg', + }); + if (error) throw error; + const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path); + return { url: data.publicUrl, type: 'image', name: attachment.name }; + } catch (err: any) { + Alert.alert(t('chat.upload_failed'), err?.message ?? ''); + return null; + } finally { + setUploading(false); + } + } + + async function handleSend() { + const content = inputText.trim(); + if (!content && !attachment) return; + if (sending || uploading) return; setSending(true); try { + let attachmentMeta: { url: string; type: string; name: string } | null = null; + if (attachment) { + attachmentMeta = await uploadAttachment(); + if (!attachmentMeta) { setSending(false); return; } + } const newMsg = await apiFetch('/api/chat/dm', { method: 'POST', - body: { receiverId: userId, ...payload }, + body: { + receiverId: userId, + content, + replyToId: replyTo?.id, + attachmentUrl: attachmentMeta?.url, + attachmentType: attachmentMeta?.type, + attachmentName: attachmentMeta?.name, + }, }); setMessages((prev) => [ ...prev, @@ -198,6 +306,8 @@ export default function DmScreen() { readAt: null, }, ]); + setInputText(''); + setAttachment(null); setReplyTo(null); queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); } catch (err) { @@ -239,8 +349,9 @@ export default function DmScreen() { return ( - {/* Header */} - + router.back()} hitSlop={8} activeOpacity={0.7}> @@ -262,53 +373,136 @@ export default function DmScreen() { - - - - {(isLoading || isFetching) && messages.length === 0 ? ( - - - - ) : messages.length === 0 ? ( - - - {t('chat.no_chats')} - - ) : ( - ( - {}} - /> - )} - keyExtractor={(m) => m.id} - contentContainerStyle={{ paddingBottom: 12, paddingTop: 8 }} - showsVerticalScrollIndicator={false} - /> - )} - - - - setReplyTo(null)} + + + {(isLoading || isFetching) && messages.length === 0 ? ( + + + + ) : messages.length === 0 ? ( + + + {t('chat.no_chats')} + + ) : ( + ( + {}} + /> + )} + keyExtractor={(m) => m.id} + contentContainerStyle={{ + paddingHorizontal: 0, + paddingTop: 12, + paddingBottom: 12 + insets.bottom + (keyboardVisible ? keyboardHeight : 0), + }} + showsVerticalScrollIndicator={false} + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" + onScroll={(e) => { + const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; + const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; + isNearBottomRef.current = distFromBottom < 80; + }} + scrollEventThrottle={100} + onContentSizeChange={() => { + if (isNearBottomRef.current) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} /> + )} + + + + + {replyTo && ( + + + + + {t('chat.reply_to')} {replyTo.nickname} + + + {replyTo.content || '…'} + + + setReplyTo(null)} activeOpacity={0.7}> + + + + )} + {attachment && ( + + + + {attachment.name} + + setAttachment(null)} activeOpacity={0.7}> + + + + )} + + + + + + {(inputText.trim().length > 0 || attachment) && ( + + {sending || uploading ? ( + + ) : ( + + )} + + )} + - + ); } @@ -321,7 +515,6 @@ function makeStyles(colors: ReturnType) { alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, - backgroundColor: colors.bg, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, @@ -353,5 +546,83 @@ function makeStyles(colors: ReturnType) { color: colors.textMuted, marginTop: 12, }, + inputBar: { + borderTopWidth: StyleSheet.hairlineWidth, + }, + replyBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + borderLeftWidth: 3, + borderLeftColor: '#007AFF', + marginHorizontal: 8, + marginTop: 6, + borderRadius: 8, + }, + replyName: { + fontSize: 11, + fontFamily: 'Nunito_700Bold', + color: '#007AFF', + }, + replyContent: { + fontSize: 11, + fontFamily: 'Nunito_400Regular', + marginTop: 1, + }, + attachBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + marginHorizontal: 8, + marginTop: 6, + borderRadius: 8, + }, + attachImg: { + width: 36, + height: 36, + borderRadius: 6, + marginRight: 8, + }, + attachName: { + flex: 1, + fontSize: 12, + fontFamily: 'Nunito_600SemiBold', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: 8, + paddingHorizontal: 12, + paddingTop: 8, + }, + addBtn: { + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + }, + textInput: { + flex: 1, + borderRadius: 22, + paddingVertical: 9, + paddingHorizontal: 16, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + maxHeight: 120, + }, + sendBtn: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + }, + sendBtnDisabled: { + opacity: 0.4, + }, }); } diff --git a/apps/rebreak-native/app/help/faq.tsx b/apps/rebreak-native/app/help/faq.tsx index 6cb28f1..b8643a6 100644 --- a/apps/rebreak-native/app/help/faq.tsx +++ b/apps/rebreak-native/app/help/faq.tsx @@ -19,6 +19,11 @@ export default function FaqScreen() { { q: t('help.faq_q6'), a: t('help.faq_a6') }, { q: t('help.faq_q7'), a: t('help.faq_a7') }, { q: t('help.faq_q8'), a: t('help.faq_a8') }, + { q: t('help.faq_q9'), a: t('help.faq_a9') }, + { q: t('help.faq_q10'), a: t('help.faq_a10') }, + { q: t('help.faq_q11'), a: t('help.faq_a11') }, + { q: t('help.faq_q12'), a: t('help.faq_a12') }, + { q: t('help.faq_q13'), a: t('help.faq_a13') }, ]; return ( diff --git a/apps/rebreak-native/app/lyra.tsx b/apps/rebreak-native/app/lyra.tsx index a7532e7..f9d4671 100644 --- a/apps/rebreak-native/app/lyra.tsx +++ b/apps/rebreak-native/app/lyra.tsx @@ -435,6 +435,10 @@ export default function CoachScreen() { } async function onMicDown() { + // micHeld guard verhindert Doppel-Starts — aber wenn ein vorheriger Fehler + // micHeld.current = true hinterlassen hat ohne isRecording zu setzen, + // wäre der Mic dauerhaft blockiert. Reset wenn State inkonsistent. + if (micHeld.current && !isRecording) micHeld.current = false; if (thinking || isTranscribing || isRecording || micHeld.current) return; if (isSpeaking) stopSpeaking(); @@ -442,9 +446,9 @@ export default function CoachScreen() { if (status !== 'granted') return; micHeld.current = true; - await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); - const rec = new Audio.Recording(); try { + await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); + const rec = new Audio.Recording(); await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY); await rec.startAsync(); recordingRef.current = rec; @@ -452,7 +456,8 @@ export default function CoachScreen() { startRecordingTimer(); } catch { micHeld.current = false; - await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); + recordingRef.current = null; + await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {}); } } @@ -957,6 +962,7 @@ const styles = StyleSheet.create({ }, recordingContainer: { flex: 1, + height: 38, flexDirection: 'row', alignItems: 'center', gap: 8, @@ -965,7 +971,6 @@ const styles = StyleSheet.create({ borderColor: 'rgba(220,38,38,0.2)', borderRadius: 22, paddingHorizontal: 12, - paddingVertical: 8, }, cancelBtn: { width: 32, diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index 19b0eb6..310d7eb 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -15,6 +15,7 @@ import { Ionicons } from '@expo/vector-icons'; import { MenuView, type MenuAction } from '@react-native-menu/menu'; import { TrueSheet } from '@lodev09/react-native-true-sheet'; import { useTranslation } from 'react-i18next'; +import Constants from 'expo-constants'; import { LanguageIcon } from '../components/icons/LanguageIcon'; import { useColors } from '../lib/theme'; import { Button } from '../components/Button'; @@ -702,17 +703,24 @@ export default function SettingsScreen() { ))} + {/* ─── Version Badge ── sichtbar für Tester bei Bug-Reports ──── */} - {t('settings.skeleton_footer')} + {'v' + + (Constants.expoConfig?.version ?? '?') + + ' (' + + (Platform.OS === 'ios' + ? (Constants.expoConfig?.ios?.buildNumber ?? '?') + : String(Constants.expoConfig?.android?.versionCode ?? '?')) + + ')'} {Platform.OS} diff --git a/apps/rebreak-native/build-config/exportOptions-adhoc.plist b/apps/rebreak-native/build-config/exportOptions-adhoc.plist new file mode 100644 index 0000000..b30abba --- /dev/null +++ b/apps/rebreak-native/build-config/exportOptions-adhoc.plist @@ -0,0 +1,25 @@ + + + + + method + ad-hoc + teamID + 84BQ7MTFYK + signingStyle + automatic + stripSwiftSymbols + + thinning + <none> + manifest + + appURL + https://mdm.rebreak.org/install/Rebreak.ipa + displayImageURL + https://mdm.rebreak.org/install/icon-small.png + fullSizeImageURL + https://mdm.rebreak.org/install/icon-large.png + + + diff --git a/apps/rebreak-native/build-config/exportOptions-tf.plist b/apps/rebreak-native/build-config/exportOptions-tf.plist new file mode 100644 index 0000000..c70c76f --- /dev/null +++ b/apps/rebreak-native/build-config/exportOptions-tf.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + teamID + 84BQ7MTFYK + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + uploadBitcode + + + diff --git a/apps/rebreak-native/components/ComposeCard.tsx b/apps/rebreak-native/components/ComposeCard.tsx index 8a4efac..38df0b3 100644 --- a/apps/rebreak-native/components/ComposeCard.tsx +++ b/apps/rebreak-native/components/ComposeCard.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { View, Text, @@ -19,6 +19,7 @@ import { apiFetch } from '../lib/api'; import { resolveAvatar } from '../lib/resolveAvatar'; import { useMe } from '../hooks/useMe'; import { useColors } from '../lib/theme'; +import { useCommunityStore } from '../stores/community'; type Props = { onPosted?: () => void; @@ -29,6 +30,7 @@ export function ComposeCard({ onPosted }: Props) { const colors = useColors(); const { me } = useMe(); const queryClient = useQueryClient(); + const setComposeInputFocused = useCommunityStore((s) => s.setComposeInputFocused); const inputRef = useRef(null); const [focused, setFocused] = useState(false); const [content, setContent] = useState(''); @@ -42,9 +44,14 @@ export function ComposeCard({ onPosted }: Props) { setContent(''); setImageUri(null); setFocused(false); + setComposeInputFocused(false); inputRef.current?.blur(); }; + useEffect(() => { + return () => setComposeInputFocused(false); + }, [setComposeInputFocused]); + const pickImage = async () => { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { @@ -113,7 +120,14 @@ export function ComposeCard({ onPosted }: Props) { ref={inputRef} value={content} onChangeText={setContent} - onFocus={() => setFocused(true)} + onFocus={() => { + setFocused(true); + setComposeInputFocused(true); + }} + onBlur={() => { + setFocused(false); + setComposeInputFocused(false); + }} placeholder={t('community.compose_placeholder')} placeholderTextColor={colors.textMuted} multiline diff --git a/apps/rebreak-native/components/NativeTabs.tsx b/apps/rebreak-native/components/NativeTabs.tsx index d37cf63..707d381 100644 --- a/apps/rebreak-native/components/NativeTabs.tsx +++ b/apps/rebreak-native/components/NativeTabs.tsx @@ -34,6 +34,7 @@ type NativeOnlyOptions = { disablePageAnimations?: boolean; scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent'; minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never'; + tabBar?: () => React.ReactNode; tabBarActiveTintColor?: string; tabBarInactiveTintColor?: string; labeled?: boolean; @@ -66,6 +67,7 @@ function NativeTabsNavigator({ disablePageAnimations, scrollEdgeAppearance, minimizeBehavior, + tabBar, tabBarActiveTintColor, tabBarInactiveTintColor, labeled = true, @@ -90,6 +92,7 @@ function NativeTabsNavigator({ void; + /** Called when user submits (keyboard "search" button or enter). */ + onSubmit?: (text: string) => void; + /** Called on every keystroke. */ + onChange?: (text: string) => void; + /** When true, shows ActivityIndicator-style pulse on mic icon. */ + micActive?: boolean; +}; + +export function SearchBarFloating({ + placeholder = 'Suchen…', + onPressMic, + onSubmit, + onChange, + micActive = false, +}: Props) { + const insets = useSafeAreaInsets(); + const colors = useColors(); + const scheme = useColorScheme(); + const [text, setText] = useState(''); + const micScale = useRef(new Animated.Value(1)).current; + + function handleTextChange(val: string) { + setText(val); + onChange?.(val); + } + + function handleSubmit() { + if (text.trim()) onSubmit?.(text.trim()); + } + + function handlePressMic() { + Animated.sequence([ + Animated.timing(micScale, { toValue: 0.85, duration: 80, useNativeDriver: true }), + Animated.timing(micScale, { toValue: 1, duration: 120, useNativeDriver: true }), + ]).start(); + onPressMic?.(); + } + + const bottomOffset = Math.max(insets.bottom, 8); + + return ( + + + {IS_IOS ? ( + + ) : null} + + + + + {onPressMic ? ( + + + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + left: 16, + right: 16, + zIndex: 100, + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 22, + borderWidth: StyleSheet.hairlineWidth, + paddingLeft: 12, + paddingRight: 8, + paddingVertical: 8, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 6, + gap: 8, + }, + blurFill: { + ...StyleSheet.absoluteFillObject, + borderRadius: 22, + overflow: 'hidden', + }, + searchIcon: { + flexShrink: 0, + }, + input: { + flex: 1, + fontSize: 16, + fontFamily: 'Nunito_400Regular', + padding: 0, + minHeight: 28, + }, + micBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, +}); diff --git a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx index 3aca1bc..b96cef3 100644 --- a/apps/rebreak-native/components/blocker/AddDomainSheet.tsx +++ b/apps/rebreak-native/components/blocker/AddDomainSheet.tsx @@ -113,7 +113,9 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { return; } const raw = (result.error ?? '').toLowerCase(); - if (raw.includes('limit_reached')) { + if (raw.includes('public_domain')) { + setError(t('blocker.error_public_domain')); + } else if (raw.includes('limit_reached')) { setError(t('blocker.error_limit_reached')); } else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) { setError(t('blocker.error_invalid_mail')); @@ -263,15 +265,17 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) { - {/* Preview card */} - + {/* Preview card — only when user has typed something */} + {input.trim().length > 0 && ( + + )} {/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */} {detected !== null && ( diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx index d606eb8..95472cb 100644 --- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -20,6 +20,8 @@ import { HalfDonut } from '../common/HalfDonut'; type Props = { visible: boolean; state: ProtectionState; + /** True wenn Gerät MDM-managed ist — versteckt Cooldown-CTA, zeigt Trustee-Hinweis. */ + mdmManaged?: boolean; onClose: () => void; onRequestDeactivation: () => void; onTalkToLyra: () => void; @@ -45,6 +47,7 @@ const SEG_REVIEW = '#f59e0b'; export function ProtectionDetailsSheet({ visible, state, + mdmManaged, onClose, onRequestDeactivation, }: Props) { @@ -212,7 +215,7 @@ export function ProtectionDetailsSheet({ - {[1, 2, 3, 4].map((n) => ( + {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ( - {/* "Schutz deaktivieren" – outline button: TouchableOpacity=card, inner View=flex-row */} - - - - - {t('blocker.more_info_title')} + {mdmManaged ? ( + /* MDM-Modus: Cooldown-Flow nicht möglich — Trustee-Hinweis statt Button */ + + + + + {t('blocker.mdm_deactivate_title')} + + + + {t('blocker.mdm_deactivate_body')} - + ) : ( + /* Normal-Modus: Cooldown-Flow */ + + + + + {t('blocker.more_info_title')} + + + + )} ); diff --git a/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx b/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx index 74d952f..6b3043f 100644 --- a/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx @@ -6,6 +6,10 @@ import { useColors } from '../../lib/theme'; type Props = { state: ProtectionState; + /** True wenn Gerät MDM-managed ist — versteckt Cooldown-Hint, zeigt Trustee-Info. */ + mdmManaged?: boolean; + /** True wenn NEFilter via System-Profil aktiv ist. */ + nefilterActive?: boolean; /** Click-1 of 3-Click-Cooldown-Trigger — öffnet ProtectionDetailsSheet. */ onPressSettings: () => void; }; @@ -15,10 +19,11 @@ type Props = { * "locked in" und kann nur über den Cooldown-Flow deaktiviert werden. * Daher: KEINE Switches mehr, nur ein Settings-Icon das den 3-Click-Flow startet. */ -export function ProtectionLockedCard({ state, onPressSettings }: Props) { +export function ProtectionLockedCard({ state, mdmManaged, nefilterActive, onPressSettings }: Props) { const { t } = useTranslation(); const colors = useColors(); const isCooldown = state.phase === 'cooldownActive'; + const isMdmMode = mdmManaged || nefilterActive; const cardBg = isCooldown ? '#fef3c7' : '#dcfce7'; const cardBorder = isCooldown ? '#fcd34d' : '#86efac'; const iconBg = isCooldown ? '#fde68a' : '#bbf7d0'; @@ -26,6 +31,7 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) { const subtitle = (() => { if (isCooldown) return t('blocker.protection_subtitle_cooldown'); + if (isMdmMode) return t('blocker.protection_subtitle_mdm'); if (state.plan === 'legend') { return t('blocker.protection_subtitle_legend'); } @@ -101,17 +107,21 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) { {!isCooldown && ( - - - + + + + + )} diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx index 5227264..c58c291 100644 --- a/apps/rebreak-native/components/chat/ChatBubble.tsx +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -1,16 +1,14 @@ -import { useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, - Modal, - Platform, } from 'react-native'; import { Image } from 'expo-image'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; +import { useActionSheet } from '@expo/react-native-action-sheet'; import { useColors } from '../../lib/theme'; import { useThemeStore } from '../../stores/theme'; import { UserAvatar } from '../UserAvatar'; @@ -44,6 +42,8 @@ type Props = { isFirstInGroup?: boolean; isLastInGroup?: boolean; hideReadStatus?: boolean; + /** Direct-Message-Mode: Likes als boolean-Herz (Insta-Style) statt Count, kein Avatar-Spalte-Whatever */ + isDM?: boolean; onReply: (msg: ChatMsg) => void; onLike: (msg: ChatMsg) => void; onOpenImage: (url: string) => void; @@ -72,6 +72,7 @@ export function ChatBubble({ isFirstInGroup = true, isLastInGroup = true, hideReadStatus = false, + isDM = false, onReply, onLike, onOpenImage, @@ -80,7 +81,33 @@ export function ChatBubble({ const colors = useColors(); const styles = makeStyles(colors); const bubbleColors = useBubbleColors(); - const [actionsOpen, setActionsOpen] = useState(false); + const { showActionSheetWithOptions } = useActionSheet(); + + function openActions() { + const hasContent = msg.content !== ''; + const likeLabel = msg.likedByMe ? t('chat.unlike') : t('chat.like'); + const options: string[] = [t('chat.reply'), likeLabel]; + if (hasContent) options.push(t('chat.copy')); + options.push(t('common.cancel')); + const cancelButtonIndex = options.length - 1; + showActionSheetWithOptions( + { + options, + cancelButtonIndex, + title: hasContent + ? msg.content.length > 60 + ? msg.content.slice(0, 60) + '…' + : msg.content + : undefined, + }, + (selected?: number) => { + if (selected === undefined || selected === cancelButtonIndex) return; + if (selected === 0) onReply(msg); + else if (selected === 1) onLike(msg); + else if (selected === 2 && hasContent) copyContent(); + }, + ); + } const isImageOnly = !!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo; @@ -104,9 +131,7 @@ export function ChatBubble({ function copyContent() { if (msg.content) Clipboard.setStringAsync(msg.content); - setActionsOpen(false); } - return ( <> setActionsOpen(true)} + onLongPress={openActions} activeOpacity={1} style={[ styles.bubble, @@ -204,7 +229,7 @@ export function ChatBubble({ /> {isImageOnly && ( - {msg.likesCount > 0 && ( + {!isDM && msg.likesCount > 0 && ( @@ -254,7 +279,7 @@ export function ChatBubble({ {!isImageOnly && ( - {msg.likesCount > 0 && ( + {!isDM && msg.likesCount > 0 && ( )} + + {/* Insta-Style: kleines Herz-Badge hängt unter der Bubble (nur DM, nur wenn liked) */} + {isDM && msg.likedByMe && ( + onLike(msg)} + activeOpacity={0.7} + hitSlop={8} + style={[ + styles.dmHeartBadge, + { + alignSelf: msg.isOwn ? 'flex-end' : 'flex-start', + marginRight: msg.isOwn ? 8 : 0, + marginLeft: msg.isOwn ? 0 : 8, + }, + ]} + > + + + )} - - setActionsOpen(false)} - > - setActionsOpen(false)} activeOpacity={1}> - {}} activeOpacity={1}> - - { - setActionsOpen(false); - onReply(msg); - }} - activeOpacity={0.7} - > - - {t('chat.reply')} - - { - setActionsOpen(false); - onLike(msg); - }} - activeOpacity={0.7} - > - - - {msg.likedByMe ? t('chat.unlike') : t('chat.like')} - - - {msg.content !== '' && ( - - - {t('chat.copy')} - - )} - - - ); } @@ -418,38 +415,17 @@ function makeStyles(colors: ReturnType) { marginTop: 4, alignSelf: 'flex-end', }, - sheetBackdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.35)', - justifyContent: 'flex-end', - }, - sheet: { + dmHeartBadge: { + marginTop: -6, backgroundColor: colors.bg, - borderTopLeftRadius: 22, - borderTopRightRadius: 22, - padding: 8, - paddingBottom: Platform.OS === 'ios' ? 34 : 16, - }, - sheetGrabber: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: colors.border, - alignSelf: 'center', - marginBottom: 10, - }, - sheetItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 14, - borderRadius: 12, - }, - sheetText: { - fontSize: 15, - fontFamily: 'Nunito_600SemiBold', - color: colors.text, - marginLeft: 12, + borderRadius: 999, + paddingHorizontal: 4, + paddingVertical: 3, + shadowColor: '#000', + shadowOpacity: 0.12, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 2, + elevation: 2, }, }); } diff --git a/apps/rebreak-native/components/games/GameOverScreen.tsx b/apps/rebreak-native/components/games/GameOverScreen.tsx index 42b297f..1c94ed3 100644 --- a/apps/rebreak-native/components/games/GameOverScreen.tsx +++ b/apps/rebreak-native/components/games/GameOverScreen.tsx @@ -68,7 +68,6 @@ export function GameOverScreen({ const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); - const [shareSectionOpen, setShareSectionOpen] = useState(false); const [shareText, setShareText] = useState(''); const lyraShareTextRef = useRef(''); const [shareTextLoading, setShareTextLoading] = useState(false); @@ -77,6 +76,12 @@ export function GameOverScreen({ const [posted, setPosted] = useState(false); const [postError, setPostError] = useState(false); + // UI mode — kontrolliert welche Buttons im Footer erscheinen (immer max 2) + // 'default' → [Retry, Exit] + // 'rating' → [Cancel, Save] + // 'share' → [Cancel, Post] + const [mode, setMode] = useState<'default' | 'rating' | 'share'>('default'); + const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy'; const msg = lyraMsg(gameName, score, goodScore, isNewBest, t); const displayScore = score; @@ -115,8 +120,10 @@ export function GameOverScreen({ }, }); setSaved(true); + setMode('default'); } catch { // endpoint not yet live — silent + setMode('default'); } finally { setSaving(false); } @@ -139,7 +146,7 @@ export function GameOverScreen({ async function openShareSection() { setShareTextLoading(true); - setShareSectionOpen(true); + setMode('share'); try { const text = await fetchShareText(); lyraShareTextRef.current = text; @@ -199,7 +206,7 @@ export function GameOverScreen({ }, }); setPosted(true); - setShareSectionOpen(false); + setMode('default'); setTimeout(() => handleExit(), 1500); } catch (err) { console.error('[gameover/post] failed:', err); @@ -295,14 +302,19 @@ export function GameOverScreen({ - {/* Star rating */} + {/* Star rating — interaktiv nur im default-Mode (rating-Modus zeigt Stars + feedback unten) */} { if (!saved) setRating(v); }} + onChange={(v) => { + if (saved) return; + setRating(v); + // Tap auf Stern im default-Mode → wechselt in Rating-Mode + if (mode === 'default' && v > 0) setMode('rating'); + }} /> {saved ? ( @@ -311,46 +323,93 @@ export function GameOverScreen({ ) : null} - {/* Feedback textarea + save */} - {rating > 0 && !saved ? ( - - -