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:
parent
b8e4b02b88
commit
f318364c7e
@ -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 ||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user