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"