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:
parent
73f70b5e28
commit
c32eeeb070
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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": "إعادة تشغيل الحماية",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user