fix(protection): NEFilter retry + FamilyControls 4099 recovery sheet

Tester reports: nach 'Don't Allow' im System-Dialog reagiert Re-Request
nicht (NEFilter), plus FamilyControls wirft NSCocoaErrorDomain:4099
(XPC-Daemon-Failure). Mehrere TestFlight-User betroffen.

Swift native:
- resetUrlFilter: 800ms delay nach remove + 3x retry-loop bei code 5
- activateFamilyControls: 3x retry-loop mit Backoff bei 4099

JS:
- PermissionDeniedSheet generic via variant prop (nefilter|family_controls)
- Blocker + Onboarding: 4099-detect → Recovery-Sheet mit 3-Step-Fallback

I18n DE/EN/FR/AR: blocker.family_controls_error.* keys
This commit is contained in:
chahinebrini 2026-05-20 03:51:33 +02:00
parent 73f70b5e28
commit c32eeeb070
8 changed files with 173 additions and 53 deletions

View File

@ -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() {
</View>
)}
{/* Über-Limit Banner */}
{tier.atLimit && tier.usedSlots > tier.domainLimit && (
<View
style={{
backgroundColor: 'rgba(217,119,6,0.08)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(217,119,6,0.2)',
gap: 4,
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#d97706' }}>
{t('plan_limit.blocker_domain_over_limit', {
used: tier.usedSlots,
plan: plan.charAt(0).toUpperCase() + plan.slice(1),
max: tier.domainLimit,
})}
</Text>
</View>
)}
{/* Custom-Filter-Slot-Übersicht */}
<CustomFilterOverview
webCount={countsByType.web}
@ -423,6 +414,19 @@ export default function BlockerScreen() {
}}
/>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
<ProtectionOffSheet
visible={protectionOffOpen}
onClose={() => setProtectionOffOpen(false)}

View File

@ -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({
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.permission_denied.title')}
title={t(`${ns}.title`)}
initialHeightPct={0.62}
minHeightPct={0.35}
>
@ -89,7 +99,7 @@ export function PermissionDeniedSheet({
marginBottom: 18,
}}
>
{t('blocker.permission_denied.body')}
{t(`${ns}.body`)}
</Text>
{/* 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`)}
</Text>
</TouchableOpacity>
@ -138,7 +148,7 @@ export function PermissionDeniedSheet({
color: colors.text,
}}
>
{t('blocker.permission_denied.settings_cta')}
{t(`${ns}.settings_cta`)}
</Text>
</TouchableOpacity>
@ -161,7 +171,7 @@ export function PermissionDeniedSheet({
marginBottom: 6,
}}
>
{t('blocker.permission_denied.fallback_label')}
{t(`${ns}.fallback_label`)}
</Text>
<Text
style={{
@ -171,7 +181,7 @@ export function PermissionDeniedSheet({
color: colors.textMuted,
}}
>
{t('blocker.permission_denied.fallback_body')}
{t(`${ns}.fallback_body`)}
</Text>
</View>
</View>

View File

@ -55,6 +55,7 @@ function IosProtectionSlide({
const [phase, setPhase] = useState<IosPhase>('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}
/>
>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
finishProtectionStep();
}
return res;
}}
/>
</PreExplainer>
);
}
return null;

View File

@ -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": "إعادة تشغيل الحماية",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"