chahinebrini 627ddce995 Merge branch 'feat/ios-webcontent-layer2' — iOS Schutz-Layer-2 (webContent)
WebKit webContent-Filter via ManagedSettings (MVP-Plumbing):
applyWebContentFilter/clearWebContentFilter, gebuendelte Gambling-Domain-Liste
DE/GB/FR (Starter), JS-Bridge + Hook. Braucht nur Family Controls.
Auto-Trigger-Gating bewusst offen — TODO(layer2-gating).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:11:23 +02:00

950 lines
40 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)
}
/// Letzte `n` Einträge für Inline-Diagnose im activateUrlFilter-Ergebnis
/// (enthält auch die `[EXT ...]`-Zeilen der Control-Provider-Extension).
static func tail(_ n: Int) -> [String] {
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
return Array((defaults.stringArray(forKey: logKey) ?? []).suffix(n))
}
}
// 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") { (opts: [String: String]) async -> [String: Any] in
var error: String? = nil
var enabled = false
var statusName = "n/a"
var disconnectName: String? = nil
if #available(iOS 26.0, *) {
let pirAuthToken = opts["pirAuthToken"] ?? ""
guard let pirServerURL = URL(string: opts["pirServerURL"] ?? ""), !pirAuthToken.isEmpty else {
SharedLogStore.append("❌ [activateUrlFilter] fehlende pirServerURL/pirAuthToken")
return ["enabled": false, "error": "missing_pir_config"]
}
func name(_ s: NEURLFilterManager.Status) -> String {
switch s {
case .invalid: return "invalid"
case .stopped: return "stopped"
case .starting: return "starting"
case .running: return "running"
case .stopping: return "stopping"
@unknown default: return "unknown(\(s.rawValue))"
}
}
var phase = "loadFromPreferences"
do {
let manager = NEURLFilterManager.shared
SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
phase = "setConfiguration"
SharedLogStore.append(
"⚙️ [activateUrlFilter] setConfiguration (server=\(pirServerURL.absoluteString) tokenLen=\(pirAuthToken.count))..."
)
// pirPrivacyPassIssuerURL == pirServerURL PIRService bedient beides.
try manager.setConfiguration(
pirServerURL: pirServerURL,
pirPrivacyPassIssuerURL: pirServerURL,
pirAuthenticationToken: pirAuthToken,
controlProviderBundleIdentifier: "org.rebreak.app.URLFilterExtension"
)
// WWDC2025-NEURLFilter-Sample setzt zusätzlich `localizedDescription` +
// `prefilterFetchInterval` die hatten wir bisher NICHT. Eine fehlende
// `localizedDescription` ist ein Kandidat für das `configurationInvalid`
// bei `saveToPreferences()`.
manager.localizedDescription = "ReBreak URL-Filter"
manager.prefilterFetchInterval = 86400 // 1 Tag (wie WWDC-Sample)
manager.isEnabled = true
manager.shouldFailClosed = true
phase = "saveToPreferences"
SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences...")
try await manager.saveToPreferences()
phase = "status"
// saveToPreferences wirft NICHT, wenn iOS die Config zwar speichert,
// aber als ungültig ablehnt ("Ungültig" in Settings). Der echte
// Status ist erst nach kurzer Zeit stabil direkt nach dem Save
// steht er auf .starting. Auf stabilen Status warten (max ~3s).
var status = await manager.status
var waited = 0
while status == .starting && waited < 8 {
try? await Task.sleep(nanoseconds: 400_000_000)
status = await manager.status
waited += 1
}
statusName = name(status)
if let d = await manager.lastDisconnectError {
disconnectName = "\(d)"
SharedLogStore.append("⚠️ NEURLFilter lastDisconnectError: \(d) (rawValue=\(d.rawValue))")
}
// Wahrheit ist der Status, NICHT isEnabled (das bleibt true auch bei
// einer abgelehnten Config).
enabled = (status == .running)
SharedLogStore.append(
" NEURLFilter post-save: status=\(statusName) isEnabled=\(manager.isEnabled) disconnectError=\(disconnectName ?? "nil")"
)
if !enabled {
error = "config_invalid status=\(statusName) disconnectError=\(disconnectName ?? "none")"
SharedLogStore.append("❌ NEURLFilter nicht aktiv: \(error!)")
} else {
SharedLogStore.append("✅ NEURLFilter running")
}
} catch let e as NSError {
// lastDisconnectError auch im Throw-Fall lesen gibt oft den echten
// Grund hinter einem generischen configurationInvalid.
var disc = "n/a"
if let d = await NEURLFilterManager.shared.lastDisconnectError {
disc = "\(d)"
}
error = "[\(phase)] \(e.domain):\(e.code) \(e.localizedDescription) | lastDisconnectError=\(disc)"
SharedLogStore.append("❌ NEURLFilter enable failed: \(error!)")
}
} else {
error = "iOS 26+ erforderlich für NEURLFilter"
SharedLogStore.append("\(error!)")
}
var result: [String: Any] = ["enabled": enabled, "status": statusName]
if let d = disconnectName { result["disconnectError"] = d }
if let error = error { result["error"] = error }
// Tail des geteilten Log-Stores mitgeben enthält die [EXT ...]-Zeilen
// der Control-Provider-Extension (separater Prozess, sonst unsichtbar).
result["log"] = SharedLogStore.tail(30)
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.
if #available(iOS 26.0, *) {
do {
let manager = NEURLFilterManager.shared
try await manager.loadFromPreferences()
manager.isEnabled = false
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEURLFilter disabled + removed from preferences")
} catch {
SharedLogStore.append("⚠️ NEURLFilter 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
// NEURLFilter
var urlFilter = false
if #available(iOS 26.0, *) {
do {
let manager = NEURLFilterManager.shared
try await manager.loadFromPreferences()
// Wahrheit ist der Runtime-Status: nur .running heißt filtert
// wirklich". isEnabled bleibt true auch bei einer Ungültig"-Config.
urlFilter = (await manager.status == .running)
} 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()))
}
}
}