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,
|
||||
formatCooldownRemaining,
|
||||
} from '../lib/protection';
|
||||
import type { WebContentFilterResult } from '../modules/rebreak-protection';
|
||||
|
||||
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
||||
const POLL_MS_NORMAL = 30_000;
|
||||
@ -25,6 +26,15 @@ type UseProtectionStateReturn = {
|
||||
activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
|
||||
/** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */
|
||||
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. */
|
||||
requestDeactivation: (reason?: string) => Promise<void>;
|
||||
/** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */
|
||||
@ -199,6 +209,21 @@ export function useProtectionState(): UseProtectionStateReturn {
|
||||
return result;
|
||||
}, [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(
|
||||
async (reason?: string) => {
|
||||
await protection.requestDeactivation(reason);
|
||||
@ -221,6 +246,8 @@ export function useProtectionState(): UseProtectionStateReturn {
|
||||
activate,
|
||||
activateUrlFilter,
|
||||
activateFamilyControls,
|
||||
applyWebContentFilter,
|
||||
clearWebContentFilter,
|
||||
requestDeactivation,
|
||||
cancelDeactivation,
|
||||
};
|
||||
|
||||
@ -20,6 +20,7 @@ import type {
|
||||
SyncBlocklistOpts,
|
||||
SyncBlocklistResult,
|
||||
SystemSettingsTarget,
|
||||
WebContentFilterResult,
|
||||
} from "../modules/rebreak-protection";
|
||||
import { apiFetch } from "./api";
|
||||
|
||||
@ -190,6 +191,44 @@ export const protection = {
|
||||
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`)
|
||||
* aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen,
|
||||
* damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */
|
||||
|
||||
@ -12,6 +12,7 @@ export type {
|
||||
SyncBlocklistOpts,
|
||||
SyncBlocklistResult,
|
||||
SystemSettingsTarget,
|
||||
WebContentFilterResult,
|
||||
} from './src/RebreakProtection.types';
|
||||
|
||||
export default RebreakProtectionModule;
|
||||
|
||||
@ -23,4 +23,13 @@ Pod::Spec.new do |s|
|
||||
# Xcode-Target gepackt, NICHT in die Hauptmodul-Lib.
|
||||
s.source_files = '*.{h,m,mm,swift,hpp,cpp}'
|
||||
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
|
||||
|
||||
@ -16,6 +16,14 @@ private let LAST_SYNC_KEY = "blocklist_last_sync_at"
|
||||
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
fileprivate enum SharedLogStore {
|
||||
@ -319,11 +327,12 @@ public class RebreakProtectionModule: Module {
|
||||
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, *) {
|
||||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||
store.clearAllSettings()
|
||||
SharedLogStore.append("🔓 ManagedSettings cleared")
|
||||
SharedLogStore.append("🔓 ManagedSettings cleared (denyAppRemoval + webContent)")
|
||||
}
|
||||
|
||||
// Blocklist-Datei löschen
|
||||
@ -332,6 +341,104 @@ public class RebreakProtectionModule: Module {
|
||||
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 ─────────
|
||||
|
||||
AsyncFunction("getDeviceState") { () async -> [String: Any] in
|
||||
@ -348,10 +455,19 @@ public class RebreakProtectionModule: Module {
|
||||
// FamilyControls
|
||||
var familyControls = false
|
||||
var appDeletionLock = false
|
||||
var webContentFilter = false
|
||||
if #available(iOS 16.0, *) {
|
||||
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
|
||||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||||
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()
|
||||
@ -361,6 +477,7 @@ public class RebreakProtectionModule: Module {
|
||||
"urlFilter": urlFilter,
|
||||
"familyControls": familyControls,
|
||||
"appDeletionLock": appDeletionLock,
|
||||
"webContentFilter": webContentFilter,
|
||||
"blocklistCount": count,
|
||||
"blocklistLastSyncAt": lastSync ?? NSNull(),
|
||||
]
|
||||
@ -609,6 +726,58 @@ public class RebreakProtectionModule: Module {
|
||||
|
||||
// ─── 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 {
|
||||
guard let url = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
|
||||
|
||||
@ -9,6 +9,11 @@ export type DeviceLayers = {
|
||||
urlFilter?: boolean;
|
||||
familyControls?: 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
|
||||
vpn?: boolean;
|
||||
accessibility?: boolean;
|
||||
@ -18,6 +23,19 @@ export type DeviceLayers = {
|
||||
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 = {
|
||||
allLayersOn: boolean;
|
||||
/**
|
||||
|
||||
@ -10,6 +10,7 @@ import type {
|
||||
SyncBlocklistOpts,
|
||||
SyncBlocklistResult,
|
||||
SystemSettingsTarget,
|
||||
WebContentFilterResult,
|
||||
} from './RebreakProtection.types';
|
||||
|
||||
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
|
||||
@ -52,6 +53,25 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
||||
*/
|
||||
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. */
|
||||
getDeviceState(): Promise<DeviceLayers>;
|
||||
|
||||
|
||||
@ -26,6 +26,15 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
|
||||
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> {
|
||||
return { updated: false, count: 0 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user