fix(protection): gate Family Controls on entitlement + native log viewer

Verifiziert via Build-11-.ipa-Inspektion + Device-Logs:
- com.apple.developer.family-controls FEHLT im signierten Distribution-Build
  (Plugin gated korrekt auf REBREAK_ENABLE_FAMILY_CONTROLS — Apple-
  Distribution-Approval pending).
- Device-Log beweist: ManagedSettingsAgent-XPC → "error 159 Sandbox
  restriction" → NSCocoaErrorDomain:4099. Kein Daemon-/Race-Problem,
  Retry zwecklos.

Root-Cause des FC-Bugs: app.config.ts hatte familyControlsEnabled hart
auf true (falscher "approved"-Kommentar) → App bot App-Lock an obwohl
sandbox-blockiert. FAMILY_CONTROLS_AVAILABLE wurde nirgends konsumiert.

Fixes:
- app.config.ts: familyControlsEnabled an REBREAK_ENABLE_FAMILY_CONTROLS
  gekoppelt (== Plugin-Gating) → in TestFlight/production false.
- blocker.tsx: iOS App-Lock-Card nur wenn FAMILY_CONTROLS_AVAILABLE.
  lockedIn akzeptiert URL-Filter allein wenn FC build-seitig fehlt.
- ProtectionSlide.tsx: Onboarding überspringt den App-Lock-Step (FC)
  wenn nicht verfügbar — URL-Filter allein = vollwertiger Schutz.

Native Log-Viewer (für NEFilter-Debug ohne Mac/Console.app):
- Swift: getProtectionLogs/clearProtectionLogs lesen SharedLogStore aus
  App-Group-UserDefaults.
- lib/protection.ts wrapper + TS-module-types.
- debug.tsx: ProtectionLogCard (iOS) — native NEFilter/FC-Logs in der
  App sichtbar, copy/clear/refresh.
This commit is contained in:
chahinebrini 2026-05-20 05:32:06 +02:00
parent b8e4b02b88
commit f318364c7e
7 changed files with 214 additions and 12 deletions

View File

@ -131,12 +131,19 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
},
},
// Family Controls Entitlement (denyAppRemoval via ManagedSettings) ist seit
// 2026-05 für ReBreak via Apple-Entitlement-Request approved und in TestFlight-
// sowie production-Builds aktiv. Daher hart auf `true` — keine Build-Flag-Gating
// mehr nötig. Legacy `REBREAK_ENABLE_FAMILY_CONTROLS=1` aus dev-builds wird
// ignoriert (das war Übergangs-Gating vor Apple-Approval).
familyControlsEnabled: true,
// Family Controls (denyAppRemoval via ManagedSettings) braucht ein
// DISTRIBUTION-Entitlement das noch bei Apple zur Freigabe liegt. Ohne das
// Entitlement blockt iOS den ManagedSettingsAgent-XPC auf Sandbox-Ebene
// (NSCocoaErrorDomain:4099 "Sandbox restriction" — verifiziert in Build 11).
//
// Der Wert MUSS mit dem Entitlement-Gating im Plugin übereinstimmen:
// plugins/with-rebreak-protection-ios.js setzt das family-controls-Entitlement
// NUR wenn REBREAK_ENABLE_FAMILY_CONTROLS==='1'. Diese Flag ist nur in
// development-Builds gesetzt. → familyControlsEnabled IDENTISCH koppeln,
// sonst bietet die App ein Feature an das im Build sandbox-blockiert ist.
// Sobald Apple das Distribution-Entitlement freigibt: Flag in eas.json
// preview/production env setzen — dann zieht beides automatisch.
familyControlsEnabled: process.env.REBREAK_ENABLE_FAMILY_CONTROLS === "1",
apiUrl:
process.env.EXPO_PUBLIC_API_URL ||
process.env.API_URL ||

View File

@ -18,7 +18,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection } from '../../lib/protection';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors, type ColorScheme } from '../../lib/theme';
export default function BlockerScreen() {
@ -79,7 +79,11 @@ export default function BlockerScreen() {
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
const lockedIn = urlFilterActive && appDeletionLockActive;
// "lockedIn" normal = URL-Filter UND App-Lock aktiv. Wenn Family Controls
// build-seitig nicht verfügbar ist (Distribution-Entitlement pending), kann
// es keinen App-Lock geben → dann reicht der URL-Filter allein für "geschützt".
const lockedIn =
urlFilterActive && (appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
@ -275,7 +279,11 @@ export default function BlockerScreen() {
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
) : (
) : FAMILY_CONTROLS_AVAILABLE ? (
/* iOS App-Lock nur zeigen wenn das Family-Controls-Entitlement
im Build aktiv ist. Distribution-Builds ohne Apple-Approval
Card ausblenden statt ein sandbox-blockiertes Feature
anzubieten (NSCocoaErrorDomain:4099). */
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
@ -289,7 +297,7 @@ export default function BlockerScreen() {
warning={t('blocker.layers_app_lock_warning')}
lockedHint={t('blocker.layers_app_lock_locked_hint')}
/>
)}
) : null}
</View>
)}

View File

@ -120,6 +120,7 @@ export default function DebugScreen() {
<RealtimeStatusCard />
<RealtimeLogCard />
{Platform.OS === 'ios' ? <ProtectionLogCard /> : null}
<DebugStub
title="LLM-Provider Toggle"
@ -460,6 +461,130 @@ function RealtimeLogCard() {
);
}
// ─── Protection Log Card (iOS native NEFilter/FamilyControls flow) ──────────
function ProtectionLogCard() {
const colors = useColors();
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
async function loadLogs() {
setLoading(true);
try {
const next = await protection.getProtectionLogs();
// neueste oben
setLogs([...next].reverse());
} finally {
setLoading(false);
}
}
useEffect(() => {
loadLogs();
}, []);
function copyLogs() {
Clipboard.setString(logs.join('\n'));
Alert.alert('Kopiert', `${logs.length} Protection-Log-Zeilen in Zwischenablage.`);
}
async function clearLogs() {
await protection.clearProtectionLogs();
setLogs([]);
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-outline" size={18} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
Protection Log (nativ)
</Text>
<TouchableOpacity onPress={loadLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
<Ionicons name="refresh-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
<TouchableOpacity onPress={copyLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
<TouchableOpacity onPress={clearLogs} hitSlop={8} activeOpacity={0.6}>
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
</View>
{logs.length === 0 ? (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
fontStyle: 'italic',
paddingVertical: 8,
}}
>
{loading
? 'Lade...'
: 'Keine Logs — aktiviere den URL-Filter, dann auf Refresh tippen.'}
</Text>
) : (
<View style={{ maxHeight: 320, overflow: 'hidden' }}>
<ScrollView style={{ flex: 1 }} nestedScrollEnabled showsVerticalScrollIndicator>
{logs.map((line, i) => (
<Text
key={i}
style={{
fontSize: 10,
lineHeight: 15,
color: line.includes('❌') || line.includes('failed')
? '#dc2626'
: line.includes('✅')
? '#16a34a'
: colors.textMuted,
fontFamily: 'Menlo',
marginBottom: 3,
}}
>
{line}
</Text>
))}
</ScrollView>
</View>
)}
<Text
style={{
fontSize: 10,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 8,
opacity: 0.6,
}}
>
SharedLogStore max 200 Einträge, neueste oben
</Text>
</View>
);
}
function LogLine({
entry,
colors,

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection } from '../../../lib/protection';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../../lib/protection';
import RebreakProtection from '../../../modules/rebreak-protection';
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
import { OnboardingShell } from '../OnboardingShell';
@ -76,6 +76,14 @@ function IosProtectionSlide({
);
return;
}
// Family Controls (App-Lock) braucht ein Distribution-Entitlement das
// (noch) nicht freigegeben ist → in TestFlight/production-Builds ist
// FAMILY_CONTROLS_AVAILABLE=false. Dann den Lock-Step überspringen:
// URL-Filter allein = vollwertiger Schutz, der Lock ist nur Hardening.
if (!FAMILY_CONTROLS_AVAILABLE) {
finishProtectionStep();
return;
}
setPhase('preexplain_lock');
} finally {
setActivating(false);
@ -147,7 +155,13 @@ function IosProtectionSlide({
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) setPhase('preexplain_lock');
if (res.enabled) {
if (!FAMILY_CONTROLS_AVAILABLE) {
finishProtectionStep();
} else {
setPhase('preexplain_lock');
}
}
return res;
}}
/>

View File

@ -214,6 +214,27 @@ export const protection = {
return RebreakProtection.openSystemSettings(target);
},
/** iOS: native Protection-Logs (NEFilter/FamilyControls flow) für Debug-Page.
* Auf Android/Web no-op leeres Array. */
async getProtectionLogs(): Promise<string[]> {
if (Platform.OS !== "ios") return [];
try {
return await RebreakProtection.getProtectionLogs();
} catch {
return [];
}
},
/** iOS: leert die nativen Protection-Logs. No-op auf Android/Web. */
async clearProtectionLogs(): Promise<void> {
if (Platform.OS !== "ios") return;
try {
await RebreakProtection.clearProtectionLogs();
} catch {
// ignore
}
},
addLayerChangeListener(cb: (layers: DeviceLayers) => void) {
return RebreakProtection.addListener("onLayerChange", cb);
},

View File

@ -588,6 +588,23 @@ public class RebreakProtectionModule: Module {
}
}
}
// getProtectionLogs / clearProtectionLogs
//
// SharedLogStore (NEFilter/FamilyControls native flow) schreibt nach
// UserDefaults(APP_GROUP) key "url_filter_logs". Die App hat bisher KEINEN
// Weg die auszulesen TestFlight-Debugging war auf Console.app angewiesen.
// Dieser Export macht die nativen Logs in der Debug-Page sichtbar.
AsyncFunction("getProtectionLogs") { () -> [String] in
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
return defaults.stringArray(forKey: SharedLogStore.logKey) ?? []
}
AsyncFunction("clearProtectionLogs") { () -> Void in
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
defaults.removeObject(forKey: SharedLogStore.logKey)
}
}
// Helpers

View File

@ -73,6 +73,16 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
/** Öffnet System-Settings auf dem entsprechenden Tab. */
openSystemSettings(target?: SystemSettingsTarget): Promise<void>;
/**
* iOS: liest die nativen Protection-Logs (SharedLogStore NEFilter/
* FamilyControls Flow) aus dem App-Group-UserDefaults. Für Debug-Page,
* damit TestFlight-Tester den nativen Flow ohne Mac/Console.app sehen.
*/
getProtectionLogs(): Promise<string[]>;
/** iOS: leert die nativen Protection-Logs. */
clearProtectionLogs(): Promise<void>;
// ─── Android-spezifische Methoden (auf iOS undefined zur Laufzeit) ───────
/** Android: Live-Check ob unser AccessibilityService aktuell als enabled