chahinebrini 6cd3a78aaf 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>
2026-05-21 16:07:44 +02:00

870 lines
36 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
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 {
static let logKey = "url_filter_logs"
static let maxEntries = 200
static func append(_ message: String) {
NSLog("REBREAK_PROTECTION %@", message)
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
var logs = defaults.stringArray(forKey: logKey) ?? []
logs.append(entry)
if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
defaults.set(logs, forKey: logKey)
}
}
// Module
/// Vereinheitlichtes ReBreak-Schutz-Modul.
///
/// Wraps:
/// - NEFilterDataProvider Konfiguration (URL-Filter Layer)
/// - AuthorizationCenter + ManagedSettings (Family Controls denyAppRemoval)
/// - Blocklist-Sync mit Retry-Backoff
/// - End-to-End Health-Probe via hidden WKWebView
///
/// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt
/// JS-seitig (`lib/protection.ts` Backend-API).
public class RebreakProtectionModule: Module {
// Health-Probe State (UI-Thread)
private var probeWebView: WKWebView?
private var probeDelegate: HealthProbeDelegate?
public func definition() -> ModuleDefinition {
Name("RebreakProtection")
Events("onLayerChange")
// activate: Family Controls + NEFilter + denyAppRemoval
// activateUrlFilter: NUR NEFilter
AsyncFunction("activateUrlFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...")
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))")
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ NEFilter enable failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// resetUrlFilter: cached "denied" State löschen + frischer Dialog
//
// Apple's NEFilterManager cached den "permission denied"-State wenn User einmal
// "Nicht erlauben" getippt hat danach zeigt iOS den System-Dialog beim erneuten
// saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences
// löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences
// als brandneuer Permission-Request behandelt wird frischer System-Dialog.
//
// Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht
// zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery.
AsyncFunction("resetUrlFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
// Best-effort remove schlägt fehl wenn nichts existiert, das ist OK.
SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...")
do {
try await manager.removeFromPreferences()
} catch {
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
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
// 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!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// activateFamilyControls: NUR FC + denyAppRemoval
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
if #available(iOS 16.0, *) {
// 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
}
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)
}
}
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"
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// activate (legacy, alle Layer in einem Call)
AsyncFunction("activate") { () async -> [String: Any] in
var missingLayers: [String] = []
var errors: [String] = []
// 1) Family Controls Authorization (iOS 16+)
var familyControlsApproved = false
if #available(iOS 16.0, *) {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved
SharedLogStore.append("✅ FamilyControls authorized")
} catch let error as NSError {
let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
} else {
errors.append("iOS 16+ required for FamilyControls")
}
if !familyControlsApproved {
missingLayers.append("familyControls")
}
// 2) NEFilter aktivieren
var urlFilterOn = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activate] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...")
try await manager.saveToPreferences()
urlFilterOn = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))")
} catch let error as NSError {
let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
if !urlFilterOn {
missingLayers.append("urlFilter")
}
// 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved)
if #available(iOS 16.0, *), familyControlsApproved {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.application.denyAppRemoval = true
store.application.denyAppInstallation = false
SharedLogStore.append("🔒 denyAppRemoval = true")
}
return [
"allLayersOn": missingLayers.isEmpty,
"missingLayers": missingLayers,
// Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt
"errors": errors,
]
}
// disable: NUR aufrufen wenn JS-Cooldown abgelaufen!
AsyncFunction("disable") { () async -> [String: Any] in
// NEFilter robuster Disable-Path:
// 1. loadFromPreferences (current config + isEnabled state lesen)
// 2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen +
// Settings.app-UI flippt sofort auf "deaktiviert")
// 3. removeFromPreferences (Config-Eintrag aus Settings entfernen)
//
// Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen
// (insb. iOS 18+) unzuverlässig Settings-UI zeigt "Läuft..." obwohl der
// Provider beendet sein sollte. Erst isEnabled=false + save bringt das
// System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config
// löschen. Pattern aus Apple-Developer-Forums + eigene Empirie.
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
if manager.isEnabled {
manager.isEnabled = false
do {
try await manager.saveToPreferences()
SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)")
} catch {
SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)")
}
}
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEFilter disabled + removed from preferences")
} catch {
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
}
// 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 (denyAppRemoval + webContent)")
}
// Blocklist-Datei löschen
Self.clearBlocklistFile()
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
// NEFilter
var urlFilter = false
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
urlFilter = manager.isEnabled
} catch {
// ignore
}
// 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()
let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY)
return [
"urlFilter": urlFilter,
"familyControls": familyControls,
"appDeletionLock": appDeletionLock,
"webContentFilter": webContentFilter,
"blocklistCount": count,
"blocklistLastSyncAt": lastSync ?? NSNull(),
]
}
// syncBlocklist: download + atomic write + DarwinNotif
AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
)
}
guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
)
}
// Retry mit Backoff (1s, 2s) fängt iOS NECP-Race nach saveToPreferences
let maxAttempts = 3
var attempt = 1
var lastError: Error?
while attempt <= maxAttempts {
do {
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) {
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
)
}
if httpResponse.statusCode == 304 {
SharedLogStore.append("📡 sync 304 (cached)")
return ["updated": false, "count": Self.currentHashCount()]
}
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "RebreakProtection", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
guard let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
)
}
let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME)
let tmpURL = finalURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
// DiGA-Hardening
try? (tmpURL as NSURL).setResourceValue(
URLFileProtection.complete,
forKey: .fileProtectionKey
)
var mut = tmpURL
var rv = URLResourceValues()
rv.isExcludedFromBackup = true
try? mut.setResourceValues(rv)
// Atomic replace
_ = try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
// ETag + lastSync persistieren
let defaults = UserDefaults(suiteName: APP_GROUP)
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
defaults?.set(newEtag, forKey: ETAG_KEY)
}
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY)
// Extension benachrichtigen (cross-process)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
let count = data.count / 8
let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? ""
SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))")
var result: [String: Any] = ["updated": true, "count": count]
if !plan.isEmpty { result["plan"] = plan }
return result
} catch {
lastError = error
let nsError = error as NSError
let isTransient = nsError.domain == NSURLErrorDomain && (
nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotConnectToHost ||
nsError.code == NSURLErrorTimedOut ||
nsError.code == NSURLErrorNetworkConnectionLost ||
nsError.code == NSURLErrorCannotFindHost
)
if isTransient && attempt < maxAttempts {
let delaySec = UInt64(attempt)
SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s")
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
attempt += 1
continue
}
SharedLogStore.append("❌ sync failed: \(error.localizedDescription)")
throw error
}
}
throw lastError ?? NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
)
}
// runHealthProbe: hidden WKWebView gegen bet365
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
let target = (opts?["target"] as? String) ?? "https://bet365.com"
let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0
guard let url = URL(string: target) else {
return [
"outcome": "offline",
"reason": "invalid_target",
"durationMs": 0,
"target": target,
]
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async { [weak self] in
guard let self = self else {
continuation.resume(returning: [
"outcome": "offline",
"reason": "module_gone",
"durationMs": 0,
"target": target,
])
return
}
// Vorherige Probe abbrechen
self.probeWebView?.stopLoading()
self.probeWebView?.navigationDelegate = nil
self.probeWebView = nil
self.probeDelegate = nil
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
configuration: config
)
let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in
self?.probeWebView?.stopLoading()
self?.probeWebView?.navigationDelegate = nil
self?.probeWebView = nil
self?.probeDelegate = nil
switch result {
case .blocked(let reason, let durationMs):
SharedLogStore.append("🛡️ probe BLOCKED: \(target)\(reason)\(durationMs)ms")
continuation.resume(returning: [
"outcome": "blocked", "reason": reason,
"durationMs": durationMs, "target": target,
])
case .loaded(let durationMs):
SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms")
continuation.resume(returning: [
"outcome": "loaded", "reason": "page_loaded",
"durationMs": durationMs, "target": target,
])
case .offline(let reason):
continuation.resume(returning: [
"outcome": "offline", "reason": reason,
"durationMs": 0, "target": target,
])
case .timeout:
continuation.resume(returning: [
"outcome": "timeout", "reason": "no_resolution",
"durationMs": Int(timeoutSec * 1000), "target": target,
])
}
}
self.probeWebView = webView
self.probeDelegate = delegate
webView.navigationDelegate = delegate
var request = URLRequest(url: url)
request.timeoutInterval = timeoutSec
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
webView.load(request)
}
}
}
// openSystemSettings
AsyncFunction("openSystemSettings") { (target: String?) -> Void in
// Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische
// Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per
// URL-Scheme Apple deny'd das seit iOS 13. Wir öffnen die App-Settings
// unserer App; der User navigiert von dort weiter.
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
// 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
/// 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)?
.appendingPathComponent(BLOCKLIST_FILENAME),
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int
else { return 0 }
return size / 8
}
private static func clearBlocklistFile() {
if let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
.appendingPathComponent(BLOCKLIST_FILENAME) {
try? FileManager.default.removeItem(at: url)
}
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY)
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
}
}
// HealthProbeDelegate
private final class HealthProbeDelegate: NSObject, WKNavigationDelegate {
enum Outcome {
case blocked(reason: String, durationMs: Int)
case loaded(durationMs: Int)
case offline(reason: String)
case timeout
}
private let onComplete: (Outcome) -> Void
private let startTime: Date
private var resolved = false
private var timeoutItem: DispatchWorkItem?
init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) {
self.onComplete = onComplete
self.startTime = Date()
super.init()
let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) }
self.timeoutItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item)
}
private func resolveOnce(_ outcome: Outcome) {
if resolved { return }
resolved = true
timeoutItem?.cancel()
timeoutItem = nil
onComplete(outcome)
}
private func durationMs() -> Int {
return Int(Date().timeIntervalSince(startTime) * 1000)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
resolveOnce(.loaded(durationMs: durationMs()))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain &&
(nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotFindHost ||
nsError.code == NSURLErrorTimedOut) {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
}