feat(protection): iOS Layer-2 webContent-Filter (ManagedSettings) — MVP-Plumbing
WebKit-interner Content-Filter via ManagedSettingsStore().webContent als stilles Sicherheitsnetz. Blockt eine kuratierte, laenderabhaengige Top- Gambling-Domain-Liste plus systemseitig Adult-Content (.auto-Variante). Braucht NUR Family Controls — kein MDM, kein neues Entitlement, keine Config-Plugin-Aenderung. - gambling-domains.json: gebuendelte Starter-Liste (DE/GB/FR), je <=50 Domains (Apple-Hartlimit), klar als STARTER markiert. Via Podspec- resource_bundles ins App-Bundle gepackt. - applyWebContentFilter / clearWebContentFilter: zwei native AsyncFunctions. Land via Locale.current.region, iOS 16+ gegated, FC-Auth vorausgesetzt. - JS-Bridge (Module-Decl, types, web-stub, lib/protection.ts) + Actions im useProtectionState-Hook. getDeviceState liefert webContentFilter-Layer mit. KEINE Auto-Trigger-Logik — Layer 2 ist vorerst nur explizit aufrufbare Capability. Siehe TODO(layer2-gating) im Swift-Modul und lib/protection.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ea152a9169
commit
6cd3a78aaf
95
apps/rebreak-native/assets/protection/gambling-domains.json
Normal file
95
apps/rebreak-native/assets/protection/gambling-domains.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"_comment": "STARTER — kuratierte Starter-Liste der bekanntesten Gambling-Domains pro Land. NICHT die Endliste. Die finale, traffic-rangbasierte Kuratierung (via Similarweb-Ranking / GGL-Whitelist für DE) ist noch offen. Apple-Hartlimit: max. 50 Domains pro Land — diese Grenze darf NIE überschritten werden. Schlüssel = ISO-3166-1-alpha-2-Ländercode (Locale.current.region). Werte = registrierbare Domains ohne Schema/Subdomain (ManagedSettings WebDomain matched die Domain inkl. Subdomains).",
|
||||||
|
"_meta": {
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-05-21",
|
||||||
|
"maxDomainsPerCountry": 50,
|
||||||
|
"status": "starter"
|
||||||
|
},
|
||||||
|
"DE": [
|
||||||
|
"tipico.de",
|
||||||
|
"tipico.com",
|
||||||
|
"bwin.de",
|
||||||
|
"bwin.com",
|
||||||
|
"interwetten.de",
|
||||||
|
"interwetten.com",
|
||||||
|
"betano.de",
|
||||||
|
"bet-at-home.com",
|
||||||
|
"sportwetten.de",
|
||||||
|
"merkur-bets.de",
|
||||||
|
"merkurbets.de",
|
||||||
|
"happybet.de",
|
||||||
|
"neobet.de",
|
||||||
|
"winamax.de",
|
||||||
|
"betway.de",
|
||||||
|
"admiralbet.de",
|
||||||
|
"oddset.de",
|
||||||
|
"lottohelden.de",
|
||||||
|
"lotto.de",
|
||||||
|
"lotto24.de",
|
||||||
|
"jackpot.de",
|
||||||
|
"drueckglueck.de",
|
||||||
|
"loewen-play.de",
|
||||||
|
"merkur24.com",
|
||||||
|
"casino.de",
|
||||||
|
"casinos.de",
|
||||||
|
"betsson.de",
|
||||||
|
"leovegas.de",
|
||||||
|
"lapalingo.com",
|
||||||
|
"sunmaker.de"
|
||||||
|
],
|
||||||
|
"GB": [
|
||||||
|
"bet365.com",
|
||||||
|
"williamhill.com",
|
||||||
|
"skybet.com",
|
||||||
|
"skyvegas.com",
|
||||||
|
"ladbrokes.com",
|
||||||
|
"coral.co.uk",
|
||||||
|
"paddypower.com",
|
||||||
|
"betfair.com",
|
||||||
|
"betfred.com",
|
||||||
|
"unibet.co.uk",
|
||||||
|
"888.com",
|
||||||
|
"888sport.com",
|
||||||
|
"888casino.com",
|
||||||
|
"betway.com",
|
||||||
|
"virginbet.com",
|
||||||
|
"boylesports.com",
|
||||||
|
"betvictor.com",
|
||||||
|
"10bet.com",
|
||||||
|
"mrgreen.com",
|
||||||
|
"casumo.com",
|
||||||
|
"leovegas.com",
|
||||||
|
"grosvenorcasinos.com",
|
||||||
|
"mecca-bingo.com",
|
||||||
|
"gala-bingo.com",
|
||||||
|
"tombola.co.uk",
|
||||||
|
"lottoland.co.uk",
|
||||||
|
"national-lottery.co.uk",
|
||||||
|
"kwikfit-pools.co.uk",
|
||||||
|
"parimatch.co.uk",
|
||||||
|
"smarkets.com"
|
||||||
|
],
|
||||||
|
"FR": [
|
||||||
|
"winamax.fr",
|
||||||
|
"betclic.fr",
|
||||||
|
"betclic.com",
|
||||||
|
"pmu.fr",
|
||||||
|
"unibet.fr",
|
||||||
|
"parionssport.fdj.fr",
|
||||||
|
"fdj.fr",
|
||||||
|
"zebet.fr",
|
||||||
|
"vbet.fr",
|
||||||
|
"netbet.fr",
|
||||||
|
"bwin.fr",
|
||||||
|
"genybet.fr",
|
||||||
|
"zeturf.fr",
|
||||||
|
"feeling-bet.fr",
|
||||||
|
"barrierebet.fr",
|
||||||
|
"pokerstars.fr",
|
||||||
|
"partypoker.fr",
|
||||||
|
"lucien-barriere.com",
|
||||||
|
"casinobarriere.com",
|
||||||
|
"circus.be"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
type ProtectionPhase,
|
type ProtectionPhase,
|
||||||
formatCooldownRemaining,
|
formatCooldownRemaining,
|
||||||
} from '../lib/protection';
|
} from '../lib/protection';
|
||||||
|
import type { WebContentFilterResult } from '../modules/rebreak-protection';
|
||||||
|
|
||||||
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
||||||
const POLL_MS_NORMAL = 30_000;
|
const POLL_MS_NORMAL = 30_000;
|
||||||
@ -25,6 +26,15 @@ type UseProtectionStateReturn = {
|
|||||||
activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
|
activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
|
||||||
/** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */
|
/** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */
|
||||||
activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
|
activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
|
||||||
|
/**
|
||||||
|
* iOS Layer 2 — aktiviert den webContent-Filter (kuratierte Gambling-Domain-
|
||||||
|
* Liste des Geräte-Landes). Stilles Sicherheitsnetz; braucht aktive Family
|
||||||
|
* Controls. KEINE Auto-Trigger-Logik — explizit aufrufbare Capability.
|
||||||
|
* No-op auf Android/Web. Siehe TODO(layer2-gating) in lib/protection.ts.
|
||||||
|
*/
|
||||||
|
applyWebContentFilter: () => Promise<WebContentFilterResult>;
|
||||||
|
/** iOS Layer 2 — setzt den webContent-Filter zurück. Rührt den App-Lock nicht an. */
|
||||||
|
clearWebContentFilter: () => Promise<{ cleared: boolean; error?: string }>;
|
||||||
/** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */
|
/** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */
|
||||||
requestDeactivation: (reason?: string) => Promise<void>;
|
requestDeactivation: (reason?: string) => Promise<void>;
|
||||||
/** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */
|
/** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */
|
||||||
@ -199,6 +209,21 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
return result;
|
return result;
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|
||||||
|
// iOS Layer 2 — webContent-Filter. TODO(layer2-gating): bislang nur explizit
|
||||||
|
// aufrufbar; die Auto-Trigger-Logik (an wenn NEURLFilter aus + Cooldown läuft)
|
||||||
|
// ist eine offene User-Design-Entscheidung.
|
||||||
|
const applyWebContentFilter = useCallback(async () => {
|
||||||
|
const result = await protection.applyWebContentFilter();
|
||||||
|
await fetchState(false);
|
||||||
|
return result;
|
||||||
|
}, [fetchState]);
|
||||||
|
|
||||||
|
const clearWebContentFilter = useCallback(async () => {
|
||||||
|
const result = await protection.clearWebContentFilter();
|
||||||
|
await fetchState(false);
|
||||||
|
return result;
|
||||||
|
}, [fetchState]);
|
||||||
|
|
||||||
const requestDeactivation = useCallback(
|
const requestDeactivation = useCallback(
|
||||||
async (reason?: string) => {
|
async (reason?: string) => {
|
||||||
await protection.requestDeactivation(reason);
|
await protection.requestDeactivation(reason);
|
||||||
@ -221,6 +246,8 @@ export function useProtectionState(): UseProtectionStateReturn {
|
|||||||
activate,
|
activate,
|
||||||
activateUrlFilter,
|
activateUrlFilter,
|
||||||
activateFamilyControls,
|
activateFamilyControls,
|
||||||
|
applyWebContentFilter,
|
||||||
|
clearWebContentFilter,
|
||||||
requestDeactivation,
|
requestDeactivation,
|
||||||
cancelDeactivation,
|
cancelDeactivation,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type {
|
|||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
|
WebContentFilterResult,
|
||||||
} from "../modules/rebreak-protection";
|
} from "../modules/rebreak-protection";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
|
|
||||||
@ -190,6 +191,44 @@ export const protection = {
|
|||||||
return RebreakProtection.getDeviceState();
|
return RebreakProtection.getDeviceState();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── iOS Layer 2 — webContent-Filter (ManagedSettings) ───────────────────
|
||||||
|
//
|
||||||
|
// Stilles WebKit-Sicherheitsnetz: blockt eine kuratierte, länderabhängige
|
||||||
|
// Top-Gambling-Domain-Liste (≤50 Domains — Apple-Hartlimit) via
|
||||||
|
// ManagedSettings. Braucht NUR Family Controls (kein MDM, kein neues
|
||||||
|
// Entitlement). Auf Android/Web no-op.
|
||||||
|
//
|
||||||
|
// TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die
|
||||||
|
// Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1
|
||||||
|
// aus ist + ein Cooldown läuft, sonst AUS") ist bewusst NICHT gebaut — sie
|
||||||
|
// hängt an einer zuverlässigen NEURLFilter-Status-Erkennung (die aktuell
|
||||||
|
// selbst nicht stabil funktioniert) und ist eine offene Produkt-/Design-
|
||||||
|
// Entscheidung des Users. Bis dahin müssen Aufrufer applyWebContentFilter /
|
||||||
|
// clearWebContentFilter explizit triggern.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiviert Layer 2 — der webContent-Filter blockt die gebündelte Top-
|
||||||
|
* Gambling-Domain-Liste des Geräte-Landes. Setzt eine aktive Family-Controls-
|
||||||
|
* Authorization voraus. No-op auf Android/Web (gibt enabled:false zurück).
|
||||||
|
*/
|
||||||
|
async applyWebContentFilter(): Promise<WebContentFilterResult> {
|
||||||
|
if (Platform.OS !== "ios") {
|
||||||
|
return { enabled: false, appliedCount: 0, region: "", error: "ios_only" };
|
||||||
|
}
|
||||||
|
return RebreakProtection.applyWebContentFilter();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Layer 2 zurück (blockedByFilter = .none). Rührt den App-Lock
|
||||||
|
* (denyAppRemoval) NICHT an. No-op auf Android/Web.
|
||||||
|
*/
|
||||||
|
async clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }> {
|
||||||
|
if (Platform.OS !== "ios") {
|
||||||
|
return { cleared: false, error: "ios_only" };
|
||||||
|
}
|
||||||
|
return RebreakProtection.clearWebContentFilter();
|
||||||
|
},
|
||||||
|
|
||||||
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
|
/** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`)
|
||||||
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
||||||
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type {
|
|||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
|
WebContentFilterResult,
|
||||||
} from './src/RebreakProtection.types';
|
} from './src/RebreakProtection.types';
|
||||||
|
|
||||||
export default RebreakProtectionModule;
|
export default RebreakProtectionModule;
|
||||||
|
|||||||
@ -23,4 +23,13 @@ Pod::Spec.new do |s|
|
|||||||
# Xcode-Target gepackt, NICHT in die Hauptmodul-Lib.
|
# Xcode-Target gepackt, NICHT in die Hauptmodul-Lib.
|
||||||
s.source_files = '*.{h,m,mm,swift,hpp,cpp}'
|
s.source_files = '*.{h,m,mm,swift,hpp,cpp}'
|
||||||
s.exclude_files = 'RebreakURLFilter/**/*'
|
s.exclude_files = 'RebreakURLFilter/**/*'
|
||||||
|
|
||||||
|
# Layer-2 (webContent-Filter): gebündelte Gambling-Domain-Liste pro Land.
|
||||||
|
# Wird als eigenständiges Resource-Bundle (RebreakProtectionResources.bundle)
|
||||||
|
# ins App-Bundle gepackt — RebreakProtectionModule.swift liest es zur Laufzeit
|
||||||
|
# via Bundle(for:)/url(forResource:). Pfad relativ zur Podspec → zeigt auf die
|
||||||
|
# von der Aufgabe vorgegebene Quelle apps/rebreak-native/assets/protection/.
|
||||||
|
s.resource_bundles = {
|
||||||
|
'RebreakProtectionResources' => ['../../../assets/protection/gambling-domains.json']
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -16,6 +16,14 @@ private let LAST_SYNC_KEY = "blocklist_last_sync_at"
|
|||||||
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
|
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
|
||||||
private let MS_STORE_NAME = "rebreak.shield"
|
private let MS_STORE_NAME = "rebreak.shield"
|
||||||
|
|
||||||
|
// Layer 2 — webContent-Filter (ManagedSettings)
|
||||||
|
private let WEBCONTENT_BUNDLE = "RebreakProtectionResources"
|
||||||
|
private let WEBCONTENT_DOMAINS_FILE = "gambling-domains"
|
||||||
|
// Apple-Hartlimit: ManagedSettings blockt max. 50 Domains pro FilterPolicy.
|
||||||
|
// Wird hier defensiv durchgesetzt — nie mehr als 50 an blockedByFilter geben.
|
||||||
|
private let WEBCONTENT_MAX_DOMAINS = 50
|
||||||
|
private let WEBCONTENT_FALLBACK_REGION = "DE"
|
||||||
|
|
||||||
// ─── Shared Log-Store ─────────────────────────────────────────────────────────
|
// ─── Shared Log-Store ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fileprivate enum SharedLogStore {
|
fileprivate enum SharedLogStore {
|
||||||
@ -319,11 +327,12 @@ public class RebreakProtectionModule: Module {
|
|||||||
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
|
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagedSettings (löst denyAppRemoval)
|
// ManagedSettings (löst denyAppRemoval UND Layer-2-webContent-Filter —
|
||||||
|
// clearAllSettings() setzt den gesamten Store inkl. webContent zurück).
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||||
store.clearAllSettings()
|
store.clearAllSettings()
|
||||||
SharedLogStore.append("🔓 ManagedSettings cleared")
|
SharedLogStore.append("🔓 ManagedSettings cleared (denyAppRemoval + webContent)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocklist-Datei löschen
|
// Blocklist-Datei löschen
|
||||||
@ -332,6 +341,104 @@ public class RebreakProtectionModule: Module {
|
|||||||
return ["allLayersOff": true]
|
return ["allLayersOff": true]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────── Layer 2: webContent-Filter (ManagedSettings) ─────────
|
||||||
|
//
|
||||||
|
// WebKit-interner Content-Filter via ManagedSettingsStore().webContent.
|
||||||
|
// Stilles Sicherheitsnetz: blockt eine kuratierte, länderabhängige Top-
|
||||||
|
// Gambling-Domain-Liste (gebündeltes JSON, ≤50 Domains pro Land — Apple-
|
||||||
|
// Hartlimit) plus systemseitig Adult-Content gratis mit (.auto-Variante).
|
||||||
|
//
|
||||||
|
// Voraussetzung: gültige Family-Controls-Authorization (wie denyAppRemoval).
|
||||||
|
// Kein MDM, kein neues Entitlement. Wirkt nur, solange FC authorisiert ist —
|
||||||
|
// bei FC-Widerruf fällt der Filter lautlos weg (Apple-Verhalten, kein
|
||||||
|
// Callback).
|
||||||
|
//
|
||||||
|
// TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die
|
||||||
|
// Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1
|
||||||
|
// deaktiviert ist + während ein Cooldown läuft, sonst AUS") ist bewusst
|
||||||
|
// NICHT implementiert — sie hängt an einer zuverlässigen NEURLFilter-
|
||||||
|
// Status-Erkennung (die aktuell selbst nicht stabil funktioniert) und ist
|
||||||
|
// eine offene Produkt-/Design-Entscheidung des Users. Bis dahin ruft die
|
||||||
|
// JS-Schicht applyWebContentFilter/clearWebContentFilter explizit auf.
|
||||||
|
|
||||||
|
// ───────── applyWebContentFilter: Land → Domains → blockedByFilter ─────────
|
||||||
|
|
||||||
|
AsyncFunction("applyWebContentFilter") { () async -> [String: Any] in
|
||||||
|
var error: String? = nil
|
||||||
|
var enabled = false
|
||||||
|
var appliedCount = 0
|
||||||
|
var resolvedRegion = WEBCONTENT_FALLBACK_REGION
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
// Family Controls muss authorisiert sein, sonst ist webContent stumm.
|
||||||
|
guard AuthorizationCenter.shared.authorizationStatus == .approved else {
|
||||||
|
SharedLogStore.append("⚠️ [applyWebContentFilter] FamilyControls nicht authorisiert — abort")
|
||||||
|
return [
|
||||||
|
"enabled": false,
|
||||||
|
"appliedCount": 0,
|
||||||
|
"region": resolvedRegion,
|
||||||
|
"error": "family_controls_not_authorized",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Land bestimmen — Locale.current.region (Fallback: erstes Element,
|
||||||
|
// sonst "DE"). region ist iOS 16+; .regionCode als Pre-16-Fallback.
|
||||||
|
if let region = Locale.current.region?.identifier, !region.isEmpty {
|
||||||
|
resolvedRegion = region.uppercased()
|
||||||
|
} else if let region = Locale.current.regionCode, !region.isEmpty {
|
||||||
|
resolvedRegion = region.uppercased()
|
||||||
|
}
|
||||||
|
SharedLogStore.append("🌍 [applyWebContentFilter] region=\(resolvedRegion)")
|
||||||
|
|
||||||
|
// 2) Domain-Liste für das Land aus dem gebündelten JSON laden.
|
||||||
|
let domains = Self.loadWebContentDomains(forRegion: resolvedRegion)
|
||||||
|
if domains.isEmpty {
|
||||||
|
SharedLogStore.append("⚠️ [applyWebContentFilter] keine Domains für \(resolvedRegion)")
|
||||||
|
return [
|
||||||
|
"enabled": false,
|
||||||
|
"appliedCount": 0,
|
||||||
|
"region": resolvedRegion,
|
||||||
|
"error": "no_domains_for_region",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) blockedByFilter setzen. .auto(_, except:) blockt die gelisteten
|
||||||
|
// Domains PLUS systemseitig Adult-Content gratis mit. Hartlimit 50.
|
||||||
|
let webDomains = Set(domains.prefix(WEBCONTENT_MAX_DOMAINS).map { WebDomain(domain: $0) })
|
||||||
|
appliedCount = webDomains.count
|
||||||
|
|
||||||
|
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||||
|
store.webContent.blockedByFilter = .auto(webDomains, except: [])
|
||||||
|
enabled = true
|
||||||
|
SharedLogStore.append("🛡️ [applyWebContentFilter] webContent.blockedByFilter=.auto — \(appliedCount) Domains (\(resolvedRegion))")
|
||||||
|
} else {
|
||||||
|
error = "iOS 16+ required for webContent filter"
|
||||||
|
SharedLogStore.append("❌ [applyWebContentFilter] \(error!)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [String: Any] = [
|
||||||
|
"enabled": enabled,
|
||||||
|
"appliedCount": appliedCount,
|
||||||
|
"region": resolvedRegion,
|
||||||
|
]
|
||||||
|
if let error = error { result["error"] = error }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────── clearWebContentFilter: Filter zurücksetzen ─────────
|
||||||
|
|
||||||
|
AsyncFunction("clearWebContentFilter") { () async -> [String: Any] in
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||||
|
// Nur den webContent-Layer zurücksetzen — NICHT clearAllSettings(),
|
||||||
|
// damit denyAppRemoval (Layer „App-Lock") unangetastet bleibt.
|
||||||
|
store.webContent.blockedByFilter = .none
|
||||||
|
SharedLogStore.append("🔓 [clearWebContentFilter] webContent.blockedByFilter=.none")
|
||||||
|
return ["cleared": true]
|
||||||
|
}
|
||||||
|
return ["cleared": false, "error": "iOS 16+ required"]
|
||||||
|
}
|
||||||
|
|
||||||
// ───────── getDeviceState: aktueller Status aller Layer ─────────
|
// ───────── getDeviceState: aktueller Status aller Layer ─────────
|
||||||
|
|
||||||
AsyncFunction("getDeviceState") { () async -> [String: Any] in
|
AsyncFunction("getDeviceState") { () async -> [String: Any] in
|
||||||
@ -348,10 +455,19 @@ public class RebreakProtectionModule: Module {
|
|||||||
// FamilyControls
|
// FamilyControls
|
||||||
var familyControls = false
|
var familyControls = false
|
||||||
var appDeletionLock = false
|
var appDeletionLock = false
|
||||||
|
var webContentFilter = false
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
|
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
|
||||||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||||
appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
|
appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
|
||||||
|
// Layer 2 gilt als „an", wenn eine FilterPolicy ≠ .none gesetzt ist.
|
||||||
|
if let policy = store.webContent.blockedByFilter {
|
||||||
|
if case .none = policy {
|
||||||
|
webContentFilter = false
|
||||||
|
} else {
|
||||||
|
webContentFilter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = Self.currentHashCount()
|
let count = Self.currentHashCount()
|
||||||
@ -361,6 +477,7 @@ public class RebreakProtectionModule: Module {
|
|||||||
"urlFilter": urlFilter,
|
"urlFilter": urlFilter,
|
||||||
"familyControls": familyControls,
|
"familyControls": familyControls,
|
||||||
"appDeletionLock": appDeletionLock,
|
"appDeletionLock": appDeletionLock,
|
||||||
|
"webContentFilter": webContentFilter,
|
||||||
"blocklistCount": count,
|
"blocklistCount": count,
|
||||||
"blocklistLastSyncAt": lastSync ?? NSNull(),
|
"blocklistLastSyncAt": lastSync ?? NSNull(),
|
||||||
]
|
]
|
||||||
@ -609,6 +726,58 @@ public class RebreakProtectionModule: Module {
|
|||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Lädt die kuratierte Gambling-Domain-Liste für ein Land aus dem
|
||||||
|
/// gebündelten JSON (gambling-domains.json). Das JSON wird von der Podspec
|
||||||
|
/// als RebreakProtectionResources.bundle ins App-Bundle gepackt.
|
||||||
|
///
|
||||||
|
/// Struktur: `{ "DE": ["..."], "GB": [...], ... }` plus `_comment`/`_meta`-
|
||||||
|
/// Felder, die hier ignoriert werden. Liefert [] wenn das Bundle/JSON fehlt
|
||||||
|
/// oder das Land nicht gelistet ist — der Aufrufer behandelt das als
|
||||||
|
/// no_domains_for_region.
|
||||||
|
private static func loadWebContentDomains(forRegion region: String) -> [String] {
|
||||||
|
// Resource-Bundle innerhalb des Frameworks/Pods auflösen.
|
||||||
|
let moduleBundle = Bundle(for: RebreakProtectionModule.self)
|
||||||
|
var jsonURL: URL? = moduleBundle.url(
|
||||||
|
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
||||||
|
|
||||||
|
if jsonURL == nil,
|
||||||
|
let resourceBundleURL = moduleBundle.url(
|
||||||
|
forResource: WEBCONTENT_BUNDLE, withExtension: "bundle"),
|
||||||
|
let resourceBundle = Bundle(url: resourceBundleURL) {
|
||||||
|
jsonURL = resourceBundle.url(
|
||||||
|
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
||||||
|
}
|
||||||
|
// Fallback: manche Build-Konfigs flachen Resource-Bundles ins Main-Bundle.
|
||||||
|
if jsonURL == nil {
|
||||||
|
jsonURL = Bundle.main.url(
|
||||||
|
forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = jsonURL,
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else {
|
||||||
|
SharedLogStore.append("❌ [loadWebContentDomains] \(WEBCONTENT_DOMAINS_FILE).json nicht ladbar")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let domains = root[region.uppercased()] as? [String] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert,
|
||||||
|
// hart auf das Apple-Limit gekappt.
|
||||||
|
var seen = Set<String>()
|
||||||
|
var cleaned: [String] = []
|
||||||
|
for raw in domains {
|
||||||
|
let d = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if d.isEmpty || seen.contains(d) { continue }
|
||||||
|
seen.insert(d)
|
||||||
|
cleaned.append(d)
|
||||||
|
if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
private static func currentHashCount() -> Int {
|
private static func currentHashCount() -> Int {
|
||||||
guard let url = FileManager.default
|
guard let url = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
|
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
|
||||||
|
|||||||
@ -9,6 +9,11 @@ export type DeviceLayers = {
|
|||||||
urlFilter?: boolean;
|
urlFilter?: boolean;
|
||||||
familyControls?: boolean;
|
familyControls?: boolean;
|
||||||
appDeletionLock?: boolean;
|
appDeletionLock?: boolean;
|
||||||
|
/**
|
||||||
|
* Layer 2 — iOS-only. True wenn der ManagedSettings-webContent-Filter eine
|
||||||
|
* FilterPolicy ≠ .none gesetzt hat (kuratierte Gambling-Domain-Liste aktiv).
|
||||||
|
*/
|
||||||
|
webContentFilter?: boolean;
|
||||||
// Android
|
// Android
|
||||||
vpn?: boolean;
|
vpn?: boolean;
|
||||||
accessibility?: boolean;
|
accessibility?: boolean;
|
||||||
@ -18,6 +23,19 @@ export type DeviceLayers = {
|
|||||||
blocklistLastSyncAt: string | null;
|
blocklistLastSyncAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis von applyWebContentFilter (iOS Layer 2).
|
||||||
|
* `enabled` = Filter wurde gesetzt. `appliedCount` = wie viele Domains
|
||||||
|
* tatsächlich an blockedByFilter gingen (≤50, Apple-Hartlimit).
|
||||||
|
* `region` = das per Locale.current.region bestimmte Land.
|
||||||
|
*/
|
||||||
|
export type WebContentFilterResult = {
|
||||||
|
enabled: boolean;
|
||||||
|
appliedCount: number;
|
||||||
|
region: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ActivateResult = {
|
export type ActivateResult = {
|
||||||
allLayersOn: boolean;
|
allLayersOn: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
SyncBlocklistOpts,
|
SyncBlocklistOpts,
|
||||||
SyncBlocklistResult,
|
SyncBlocklistResult,
|
||||||
SystemSettingsTarget,
|
SystemSettingsTarget,
|
||||||
|
WebContentFilterResult,
|
||||||
} from './RebreakProtection.types';
|
} from './RebreakProtection.types';
|
||||||
|
|
||||||
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
|
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
|
||||||
@ -52,6 +53,25 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
|||||||
*/
|
*/
|
||||||
disable(): Promise<DisableResult>;
|
disable(): Promise<DisableResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS Layer 2 — webContent-Filter (ManagedSettings). Bestimmt das Land via
|
||||||
|
* Locale.current.region, lädt die gebündelte Top-Gambling-Domain-Liste für
|
||||||
|
* dieses Land (≤50 Domains, Apple-Hartlimit) und setzt
|
||||||
|
* `ManagedSettingsStore().webContent.blockedByFilter = .auto(...)` — blockt
|
||||||
|
* die Domains in WebKit (Safari u.a.) plus systemseitig Adult-Content.
|
||||||
|
*
|
||||||
|
* Setzt eine gültige Family-Controls-Authorization voraus (wie der App-Lock).
|
||||||
|
* Auf Android/iOS<16 no-op. Stilles Sicherheitsnetz; KEINE Auto-Trigger-Logik
|
||||||
|
* — muss explizit aufgerufen werden (siehe TODO(layer2-gating) im Swift-Modul).
|
||||||
|
*/
|
||||||
|
applyWebContentFilter(): Promise<WebContentFilterResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS Layer 2 — setzt den webContent-Filter zurück (blockedByFilter = .none).
|
||||||
|
* Rührt denyAppRemoval (App-Lock) NICHT an. Auf Android/iOS<16 no-op.
|
||||||
|
*/
|
||||||
|
clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }>;
|
||||||
|
|
||||||
/** Aktueller Device-State. Polling- und Health-Check-Pfad. */
|
/** Aktueller Device-State. Polling- und Health-Check-Pfad. */
|
||||||
getDeviceState(): Promise<DeviceLayers>;
|
getDeviceState(): Promise<DeviceLayers>;
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,15 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
|
|||||||
return { blocklistCount: 0, blocklistLastSyncAt: null };
|
return { blocklistCount: 0, blocklistLastSyncAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iOS Layer 2 — webContent-Filter. Auf Web inhärent ohne Funktion.
|
||||||
|
async applyWebContentFilter() {
|
||||||
|
return { enabled: false, appliedCount: 0, region: '', error: 'web_stub' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearWebContentFilter() {
|
||||||
|
return { cleared: false, error: 'web_stub' };
|
||||||
|
}
|
||||||
|
|
||||||
async syncBlocklist(): Promise<SyncBlocklistResult> {
|
async syncBlocklist(): Promise<SyncBlocklistResult> {
|
||||||
return { updated: false, count: 0 };
|
return { updated: false, count: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user