diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 0d78dd4..474af18 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -69,6 +69,7 @@ export default function BlockerScreen() { const [detailsOpen, setDetailsOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); + const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false); const [protectionOffOpen, setProtectionOffOpen] = useState(false); const urlFilterActive = state?.layers.urlFilter === true; @@ -156,6 +157,18 @@ export default function BlockerScreen() { // `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden // geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap). if (!result.enabled && result.error !== 'accessibility_pending') { + // iOS: NSCocoaErrorDomain code 4099 = XPC-Communication-Failure zum + // FamilyControls-Daemon (häufig nach „Don't Allow" oder bei iOS-Auth- + // Daemon-State-Issue). Recovery-Sheet statt rohem Alert — mit Anleitung + // (Restart Device / Settings / App reinstall). + const isXpcFailure = + Platform.OS === 'ios' && + typeof result.error === 'string' && + /NSCocoaErrorDomain:\s*4099/i.test(result.error); + if (isXpcFailure) { + setFamilyControlsErrorOpen(true); + return result; + } Alert.alert( t('blocker.activate_app_lock_failed_title'), result.error ?? t('blocker.activate_app_lock_failed_msg'), @@ -309,28 +322,6 @@ export default function BlockerScreen() { )} - {/* Über-Limit Banner */} - {tier.atLimit && tier.usedSlots > tier.domainLimit && ( - - - {t('plan_limit.blocker_domain_over_limit', { - used: tier.usedSlots, - plan: plan.charAt(0).toUpperCase() + plan.slice(1), - max: tier.domainLimit, - })} - - - )} - {/* Custom-Filter-Slot-Übersicht */} + setFamilyControlsErrorOpen(false)} + variant="family_controls" + onRetry={async () => { + const res = await protection.activateFamilyControls(); + if (res.enabled) { + await refresh(); + } + return res; + }} + /> + setProtectionOffOpen(false)} diff --git a/apps/rebreak-native/components/PermissionDeniedSheet.tsx b/apps/rebreak-native/components/PermissionDeniedSheet.tsx index 811b35a..1c37127 100644 --- a/apps/rebreak-native/components/PermissionDeniedSheet.tsx +++ b/apps/rebreak-native/components/PermissionDeniedSheet.tsx @@ -18,20 +18,30 @@ import { FormSheet } from './FormSheet'; * * Plus ein 3-Step-Fallback-Hinweis für den Härtefall (App neu installieren). */ +export type PermissionDeniedVariant = 'nefilter' | 'family_controls'; + export function PermissionDeniedSheet({ visible, onClose, onRetry, + variant = 'nefilter', }: { visible: boolean; onClose: () => void; - /** wird aufgerufen wenn User „Erneut versuchen" tappt — soll protection.resetUrlFilter() callen */ + /** wird aufgerufen wenn User „Erneut versuchen" tappt — soll protection.resetUrlFilter() + * bzw. protection.activateFamilyControls() callen (je nach variant) */ onRetry: () => Promise<{ enabled: boolean; error?: string }>; + /** Default 'nefilter' (URL-Filter denied). 'family_controls' = App-Lock-XPC-4099-Issue */ + variant?: PermissionDeniedVariant; }) { const colors = useColors(); const { t } = useTranslation(); const [retrying, setRetrying] = useState(false); + // i18n-Keys per Variant — eigenes namespace damit DE/EN/FR/AR separat verfasst werden können. + const ns = + variant === 'family_controls' ? 'blocker.family_controls_error' : 'blocker.permission_denied'; + async function handleRetry() { if (retrying) return; setRetrying(true); @@ -55,7 +65,7 @@ export function PermissionDeniedSheet({ @@ -89,7 +99,7 @@ export function PermissionDeniedSheet({ marginBottom: 18, }} > - {t('blocker.permission_denied.body')} + {t(`${ns}.body`)} {/* Primary Retry */} @@ -114,8 +124,8 @@ export function PermissionDeniedSheet({ }} > {retrying - ? t('blocker.permission_denied.retry_loading') - : t('blocker.permission_denied.retry_cta')} + ? t(`${ns}.retry_loading`) + : t(`${ns}.retry_cta`)} @@ -138,7 +148,7 @@ export function PermissionDeniedSheet({ color: colors.text, }} > - {t('blocker.permission_denied.settings_cta')} + {t(`${ns}.settings_cta`)} @@ -161,7 +171,7 @@ export function PermissionDeniedSheet({ marginBottom: 6, }} > - {t('blocker.permission_denied.fallback_label')} + {t(`${ns}.fallback_label`)} - {t('blocker.permission_denied.fallback_body')} + {t(`${ns}.fallback_body`)} diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx index 3c392dc..4cd3aa9 100644 --- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx +++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx @@ -55,6 +55,7 @@ function IosProtectionSlide({ const [phase, setPhase] = useState('preexplain_url'); const [activating, setActivating] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); + const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false); async function activateUrlFilter() { if (activating) return; @@ -87,6 +88,15 @@ function IosProtectionSlide({ try { const res = await protection.activateFamilyControls(); if (!res.enabled) { + // iOS NSCocoaErrorDomain:4099 = XPC-Communication-Failure (FamilyControls-Daemon + // nicht erreichbar). Recovery-Sheet statt Alert-Loop — gibt User klare Anleitung + // (Reboot/Settings/Reinstall) statt nur einen "Skip"-Button. + const isXpcFailure = + typeof res.error === 'string' && /NSCocoaErrorDomain:\s*4099/i.test(res.error); + if (isXpcFailure) { + setFamilyControlsErrorOpen(true); + return; + } Alert.alert( t('onboarding.protection.applock_failed_title'), res.error ?? t('onboarding.protection.applock_failed_msg'), @@ -159,7 +169,20 @@ function IosProtectionSlide({ onActivate={activateAppLock} current={current} total={total} - /> + > + setFamilyControlsErrorOpen(false)} + variant="family_controls" + onRetry={async () => { + const res = await protection.activateFamilyControls(); + if (res.enabled) { + finishProtectionStep(); + } + return res; + }} + /> + ); } return null; diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 6e3e40f..fc8e8f5 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -267,6 +267,15 @@ "fallback_label": "إذا لم تظهر النافذة", "fallback_body": "الإعدادات → مدة استخدام الجهاز → القيود — يجب السماح بـ VPN/الفلتر. كحل أخير: احذف التطبيق وأعد تثبيته من TestFlight." }, + "family_controls_error": { + "title": "تعذّر تفعيل قفل التطبيق", + "body": "لا يستطيع iOS التواصل مع خدمة مدة استخدام الجهاز الآن. يحدث هذا أحيانًا بعد «عدم السماح» أو عندما تتوقف الخدمة في الخلفية.", + "retry_cta": "حاول مرة أخرى", + "retry_loading": "لحظة من فضلك...", + "settings_cta": "فتح الإعدادات", + "fallback_label": "إذا استمرت المشكلة", + "fallback_body": "1. أعد تشغيل الـ iPhone. 2. في الإعدادات تأكد من تسجيل الدخول بـ Apple ID وأن مدة استخدام الجهاز مفعّلة. 3. كحل أخير: احذف التطبيق وأعد تثبيته من TestFlight." + }, "protection_off_title": "الحماية معطّلة", "protection_off_message": "الفلتر لا يعمل حالياً مع أنه يجب أن يكون نشطاً. هل تريد إعادة تشغيله؟", "reactivate_btn": "إعادة تشغيل الحماية", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 5cc2c99..b5338cc 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -281,6 +281,15 @@ "fallback_label": "Wenn der Dialog nicht kommt", "fallback_body": "Einstellungen → Bildschirmzeit → Inhalt & Datenschutz prüfen (VPN/Filter müssen erlaubt sein). Notfalls: App deinstallieren + via TestFlight neu installieren." }, + "family_controls_error": { + "title": "App-Lock konnte nicht aktiviert werden", + "body": "iOS kann gerade nicht mit dem Bildschirmzeit-Dienst kommunizieren. Das passiert manchmal nach „Nicht erlauben\" oder wenn der Hintergrund-Dienst hängt.", + "retry_cta": "Erneut versuchen", + "retry_loading": "Einen Moment...", + "settings_cta": "Einstellungen öffnen", + "fallback_label": "Wenn es weiterhin nicht funktioniert", + "fallback_body": "1. iPhone einmal neu starten. 2. In Einstellungen prüfen, dass du mit deiner Apple-ID angemeldet bist und Bildschirmzeit aktiviert ist. 3. Notfalls: App löschen und via TestFlight neu installieren." + }, "protection_off_title": "Schutz ist aus", "protection_off_message": "Der Filter läuft gerade nicht, sollte aber an sein. Willst du ihn wieder einschalten?", "reactivate_btn": "Schutz wieder einschalten", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index b9343ed..19dff14 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -278,6 +278,15 @@ "fallback_label": "If the dialog doesn't appear", "fallback_body": "Settings → Screen Time → Content & Privacy — VPN/Filter must be allowed. As a last resort: delete the app and reinstall via TestFlight." }, + "family_controls_error": { + "title": "App-Lock couldn't activate", + "body": "iOS can't communicate with the Screen Time service right now. This sometimes happens after \"Don't Allow\" or when the background service is stuck.", + "retry_cta": "Try again", + "retry_loading": "One moment...", + "settings_cta": "Open Settings", + "fallback_label": "If it still doesn't work", + "fallback_body": "1. Restart your iPhone. 2. In Settings, make sure you're signed in with your Apple ID and Screen Time is enabled. 3. Last resort: delete the app and reinstall via TestFlight." + }, "activate_url_failed_title": "Could not activate URL filter", "activate_url_failed_msg": "Unknown error.\nYou can try again or check System Settings.", "activate_settings_btn": "Settings", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 670fea2..e096ad9 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -267,6 +267,15 @@ "fallback_label": "Si la fenêtre n'apparaît plus", "fallback_body": "Réglages → Temps d'écran → Contenu et confidentialité — VPN/Filtre doivent être autorisés. En dernier recours : supprime l'app et réinstalle via TestFlight." }, + "family_controls_error": { + "title": "App-Lock n'a pas pu être activé", + "body": "iOS n'arrive pas à communiquer avec le service Temps d'écran. Cela arrive parfois après « Refuser » ou quand le service en arrière-plan est bloqué.", + "retry_cta": "Réessayer", + "retry_loading": "Un instant...", + "settings_cta": "Ouvrir les Réglages", + "fallback_label": "Si ça ne marche toujours pas", + "fallback_body": "1. Redémarre ton iPhone. 2. Dans Réglages, vérifie que tu es connecté avec ton Apple ID et que Temps d'écran est activé. 3. En dernier recours : supprime l'app et réinstalle via TestFlight." + }, "protection_off_title": "La protection est désactivée", "protection_off_message": "Le filtre ne fonctionne pas alors qu'il devrait être actif. Voulez-vous le réactiver ?", "reactivate_btn": "Réactiver la protection", diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index f63e7b0..6b72a24 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -116,6 +116,12 @@ public class RebreakProtectionModule: Module { SharedLogStore.append("ℹ️ removeFromPreferences ignored: \(error.localizedDescription)") } + // NEAgent braucht Zeit den remove zu propagieren bevor ein neuer save + // als "frischer Permission-Request" behandelt wird. Ohne Pause feuert + // iOS oft direkt wieder NEFilterErrorDomain:5 (cached denied). + // 800ms aus empirischen Tests (iOS 17/18). + try? await Task.sleep(nanoseconds: 800_000_000) + // Frische Config setzen + neu speichern → iOS zeigt jetzt frischen Dialog. let config = NEFilterProviderConfiguration() config.filterBrowsers = true @@ -124,10 +130,32 @@ public class RebreakProtectionModule: Module { manager.localizedDescription = "Rebreak URL Filter" manager.isEnabled = true - SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences (fresh System-Dialog)...") - try await manager.saveToPreferences() - enabled = manager.isEnabled - SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))") + // Retry-Loop: bis 3 Versuche mit exponentiellem Backoff. Apple hat + // seit iOS 17 den denied-cache hardened — manchmal braucht's mehrere + // saves bis der System-Dialog wirklich aufpoppt. + var lastError: NSError? = nil + for attempt in 1...3 { + SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences attempt \(attempt)/3...") + do { + try await manager.saveToPreferences() + enabled = manager.isEnabled + SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))") + lastError = nil + break + } catch let e as NSError { + lastError = e + SharedLogStore.append("⚠️ saveToPreferences attempt \(attempt) failed: \(e.domain):\(e.code)") + // Wenn code != 5 (denied): nicht retry, das ist anderer Fehler. + if !(e.domain == "NEFilterErrorDomain" && e.code == 5) { + throw e + } + // Sonst: kurzes Wait + nochmal versuchen + try? await Task.sleep(nanoseconds: UInt64(attempt) * 600_000_000) + } + } + if let lastError = lastError { + throw lastError + } } catch let e as NSError { error = "\(e.domain):\(e.code) \(e.localizedDescription)" SharedLogStore.append("❌ resetUrlFilter failed: \(error!)") @@ -143,26 +171,45 @@ public class RebreakProtectionModule: Module { var error: String? = nil var enabled = false if #available(iOS 16.0, *) { - do { - try await AuthorizationCenter.shared.requestAuthorization(for: .individual) - let authorized = AuthorizationCenter.shared.authorizationStatus == .approved - SharedLogStore.append("✅ FamilyControls authorized (\(authorized))") - if authorized { - let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) - store.application.denyAppRemoval = true - store.application.denyAppInstallation = false - let lockActive = (store.application.denyAppRemoval as? Bool) == true - enabled = lockActive - SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)") - if !lockActive { - error = "denyAppRemoval_not_active" + // Retry-Loop: FamilyControls XPC-Daemon kann auf den ersten Call + // mit NSCocoaErrorDomain:4099 antworten (Communication-Failure, oft + // direkt nach App-Start oder nach NEFilter-Activation). 3 Versuche + // mit Backoff lösen das in der Praxis. + var lastError: NSError? = nil + for attempt in 1...3 { + do { + SharedLogStore.append("🔐 [activateFamilyControls] requestAuthorization attempt \(attempt)/3...") + try await AuthorizationCenter.shared.requestAuthorization(for: .individual) + let authorized = AuthorizationCenter.shared.authorizationStatus == .approved + SharedLogStore.append("✅ FamilyControls authorized (\(authorized))") + lastError = nil + if authorized { + let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) + store.application.denyAppRemoval = true + store.application.denyAppInstallation = false + let lockActive = (store.application.denyAppRemoval as? Bool) == true + enabled = lockActive + SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)") + if !lockActive { + error = "denyAppRemoval_not_active" + } + } else { + enabled = false } - } else { - enabled = false + break + } catch let e as NSError { + lastError = e + SharedLogStore.append("⚠️ FamilyControls attempt \(attempt) failed: \(e.domain):\(e.code) \(e.localizedDescription)") + // Nur retryen wenn 4099 (XPC-Daemon nicht erreichbar). Sonst sofort fail. + if !(e.domain == "NSCocoaErrorDomain" && e.code == 4099) { + break + } + try? await Task.sleep(nanoseconds: UInt64(attempt) * 700_000_000) } - } catch let e as NSError { - error = "\(e.domain):\(e.code) \(e.localizedDescription)" - SharedLogStore.append("❌ FamilyControls auth failed: \(error!)") + } + if let lastError = lastError, error == nil { + error = "\(lastError.domain):\(lastError.code) \(lastError.localizedDescription)" + SharedLogStore.append("❌ FamilyControls auth failed (all retries): \(error!)") } } else { error = "iOS 16+ required for FamilyControls"