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" // Runtime-Sync-Cache: die vom Backend geholte Domain-Liste landet als // webcontent-domains.json im App-Group-Container. loadWebContentDomains liest // cache-first; die gebündelte gambling-domains.json bleibt Offline-Seed/Fallback. private let WEBCONTENT_CACHE_FILENAME = "webcontent-domains.json" private let WEBCONTENT_ETAG_KEY = "webcontent_domains_etag" private let WEBCONTENT_LAST_SYNC_KEY = "webcontent_domains_last_sync_at" // Layer 1 (NEU) — Packet-Tunnel-DNS-Filter (NEPacketTunnelProvider). // Ersetzt NEURLFilter als primären, lieferbaren iOS-Filter (NEURLFilter ist // Apple-seitig blockiert; der Code bleibt als iOS-26-Upgrade-Pfad erhalten). // Bundle-ID MUSS exakt zu plugins/with-rebreak-protection-ios.js + app.config.ts // (appExtensions) passen. private let PACKET_TUNNEL_BUNDLE_ID = "org.rebreak.app.PacketTunnelExtension" // Lesbarer Name für den iOS-VPN-Settings-Eintrag. private let PACKET_TUNNEL_DESCRIPTION = "ReBreak Schutz" // App-Group-Flags, die die Packet-Tunnel-Extension spiegelt (Tunnel-Lifecycle). private let VPN_TUNNEL_RUNNING_KEY = "vpn_tunnel_running" private let VPN_TUNNEL_REVOKED_KEY = "vpn_tunnel_revoked_by_user" // ─── 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: Layer 1 = Packet-Tunnel-DNS-Filter ───────── // // NEU (2026-05-21): Default-Layer-1 ist der NEPacketTunnelProvider-DNS- // Sinkhole — MDM-frei, ab iOS 16, Parität zum Android-VPN-Filter. // NEURLFilter (iOS 26) bleibt als Code erhalten (siehe `activateNeUrlFilter` // unten), wird aber NICHT mehr der Default — Apple hat den Stack blockiert. // // WICHTIG: nie zwei Layer-1-Filter gleichzeitig. `activateUrlFilter` startet // ausschließlich den Packet-Tunnel. AsyncFunction("activateUrlFilter") { (_: [String: String]) async -> [String: Any] in var error: String? = nil var enabled = false var statusName = "n/a" do { SharedLogStore.append("📥 [activateUrlFilter] Packet-Tunnel — loadAllFromPreferences...") let manager = try await Self.loadOrCreateTunnelManager() // Tunnel starten. saveToPreferences() löst beim allerersten Mal den // iOS-VPN-System-Permission-Dialog aus. try await manager.saveToPreferences() // Nach saveToPreferences muss neu geladen werden, sonst wirft // startVPNTunnel() NEVPNError.configurationStale. try await manager.loadFromPreferences() guard let session = manager.connection as? NETunnelProviderSession else { error = "tunnel_session_unavailable" SharedLogStore.append("❌ [activateUrlFilter] keine NETunnelProviderSession") var r: [String: Any] = ["enabled": false, "status": "n/a"] r["error"] = error r["log"] = SharedLogStore.tail(30) return r } SharedLogStore.append("🚀 [activateUrlFilter] startVPNTunnel...") try session.startVPNTunnel() // Auf .connected warten. Direkt nach startVPNTunnel() steht der Status // oft noch kurz auf .disconnected, BEVOR er auf .connecting → .connected // wechselt. Daher pollen bis .connected ODER Timeout (≈6s) — NICHT nur // solange .connecting (sonst false-negative tunnel_not_connected in der // frühen .disconnected-Phase). .invalid = Konfig-Fehler → sofort raus. var status = manager.connection.status var waited = 0 while status != .connected && status != .invalid && waited < 15 { try? await Task.sleep(nanoseconds: 400_000_000) status = manager.connection.status waited += 1 } statusName = Self.tunnelStatusName(status) enabled = (status == .connected) // App-Group-Flags spiegeln (die Extension setzt sie ebenfalls; hier // setzen wir sie optimistisch, damit getDeviceState konsistent ist). if let d = UserDefaults(suiteName: APP_GROUP) { d.set(enabled, forKey: VPN_TUNNEL_RUNNING_KEY) d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY) } if enabled { SharedLogStore.append("✅ Packet-Tunnel connected") } else { error = "tunnel_not_connected status=\(statusName)" SharedLogStore.append("❌ Packet-Tunnel nicht aktiv: \(error!)") } } catch let e as NSError { error = "\(e.domain):\(e.code) \(e.localizedDescription)" SharedLogStore.append("❌ [activateUrlFilter] Packet-Tunnel failed: \(error!)") } var result: [String: Any] = ["enabled": enabled, "status": statusName] if let error = error { result["error"] = error } result["log"] = SharedLogStore.tail(30) return result } // ───────── activateNeUrlFilter: NEURLFilter (iOS 26) — INAKTIV ───────── // // Behalten als optionaler iOS-26-Upgrade-Pfad (User-Entscheidung). Wird // NICHT mehr vom Default-Flow aufgerufen — `activateUrlFilter` startet // stattdessen den Packet-Tunnel. Sobald Apple den NEURLFilter-DTS-Bug // fixt, kann dieser Pfad als „privacy-besserer" Filter (PIR) reaktiviert // werden. Funktion bleibt registriert, damit der Code build-bar bleibt. AsyncFunction("activateNeUrlFilter") { (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 var webContentCount = 0 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" } // Layer 2 — der webContent-Filter ist Teil des FC-Schutzes: // greift mit, sobald FC aktiv ist (stilles Sicherheitsnetz für // den Fall, dass der User Layer 1 / VPN abschaltet). Best-effort // — ein Fehlschlag hier kippt die FC-Aktivierung NICHT. let wc = Self.applyWebContentLayer() webContentCount = wc.count } 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, "webContentDomains": webContentCount] 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") // Layer 2 — webContent-Filter als Teil des FC-Schutzes mit-aktivieren. _ = Self.applyWebContentLayer() } 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. // Layer 1 = Packet-Tunnel-DNS-Filter stoppen + Config entfernen. do { let managers = try await NETunnelProviderManager.loadAllFromPreferences() if let manager = Self.findRebreakTunnel(in: managers) { if let session = manager.connection as? NETunnelProviderSession { session.stopVPNTunnel() } try await manager.removeFromPreferences() SharedLogStore.append("✅ Packet-Tunnel stopped + removed from preferences") } else { SharedLogStore.append("ℹ️ Packet-Tunnel disable: keine Config vorhanden") } } catch { SharedLogStore.append("⚠️ Packet-Tunnel disable: \(error.localizedDescription)") } // App-Group-Tunnel-Flags zurücksetzen. if let d = UserDefaults(suiteName: APP_GROUP) { d.removeObject(forKey: VPN_TUNNEL_RUNNING_KEY) d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY) } // NEURLFilter (iOS 26) defensiv ebenfalls deaktivieren — falls ein // früherer Build NEURLFilter aktiviert hatte. Bleibt als Code erhalten. 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", ] } // Gemeinsame Layer-2-Logik (applyWebContentLayer) — exakt dieselbe, // die auch bei der FC-Aktivierung mitläuft. let wc = Self.applyWebContentLayer() enabled = wc.enabled appliedCount = wc.count resolvedRegion = wc.region if !enabled { error = "no_domains_for_region" } } 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 // Layer 1 = Packet-Tunnel-DNS-Filter. Wahrheit ist der Runtime-Status // des NETunnelProviderManager — nur .connected heißt „filtert wirklich". // (NEURLFilter ist nicht mehr der Default-Filter; sein Status fließt // bewusst NICHT mehr in den `urlFilter`-Slot ein.) var urlFilter = false do { let managers = try await NETunnelProviderManager.loadAllFromPreferences() if let manager = Self.findRebreakTunnel(in: managers) { urlFilter = (manager.connection.status == .connected) } } catch { // ignore — kein Tunnel konfiguriert → urlFilter bleibt false. } // 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"] ) } // ───────── syncWebContentDomains: Layer-2-Domain-Liste vom Backend ───────── // // Gespiegelt von `syncBlocklist` (oben). Holt die kuratierte Gambling- // Domain-Liste (`GET /api/protection/webcontent-domains`) und schreibt das // JSON atomar in den App-Group-Container als `webcontent-domains.json`. // `loadWebContentDomains` liest danach cache-first aus dieser Datei; die // gebündelte `gambling-domains.json` bleibt nur noch Offline-Seed/Fallback. // // URL-/Auth-Handling exakt wie `syncBlocklist`: baseURL + authToken kommen // aus JS (`opts`), Bearer-Auth, ETag/If-None-Match, Retry mit Backoff. // // Nach erfolgreichem Sync wird — wenn Family Controls authorisiert ist — // `applyWebContentLayer()` erneut aufgerufen, damit die neue Liste sofort // greift. Solange der Backend-Endpoint nicht deployed ist, schlägt der // Fetch fehl → `loadWebContentDomains` fällt sauber auf die gebündelte // JSON zurück (App funktioniert offline weiter). AsyncFunction("syncWebContentDomains") { (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/protection/webcontent-domains") else { throw NSError( domain: "RebreakProtection", code: 400, userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"] ) } // Retry mit Backoff (1s, 2s) — fängt transiente Netzwerk-Fehler. 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: WEBCONTENT_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("📡 [webContent] sync 304 (cached)") return ["updated": false] } guard httpResponse.statusCode == 200 else { throw NSError( domain: "RebreakProtection", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"] ) } // Vor dem Schreiben validieren: muss parsebares JSON mit Country-Keys // sein (gleiche Struktur wie die gebündelte gambling-domains.json). // Eine kaputte Response NICHT in den Cache schreiben — sonst killt // sie den Fallback. guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NSError( domain: "RebreakProtection", code: 422, userInfo: [NSLocalizedDescriptionKey: "response is not valid JSON object"] ) } // Mindestens ein Country-Key mit einem String-Array erwartet. let countryKeyCount = root.keys.filter { !$0.hasPrefix("_") }.count guard countryKeyCount > 0 else { throw NSError( domain: "RebreakProtection", code: 422, userInfo: [NSLocalizedDescriptionKey: "response has no country keys"] ) } 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(WEBCONTENT_CACHE_FILENAME) let tmpURL = finalURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) // DiGA-Hardening (gespiegelt von syncBlocklist). 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: WEBCONTENT_ETAG_KEY) } defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: WEBCONTENT_LAST_SYNC_KEY) let version = ((root["_meta"] as? [String: Any])?["version"] as? Int) ?? 0 SharedLogStore.append( "📡 [webContent] sync ok: \(countryKeyCount) Länder (version=\(version))" ) // Reapply: wenn Family Controls authorisiert ist, die neue Liste // sofort wirksam machen. Best-effort — ein Fehlschlag hier kippt den // erfolgreichen Sync NICHT. var reapplied = false if #available(iOS 16.0, *), AuthorizationCenter.shared.authorizationStatus == .approved { let wc = Self.applyWebContentLayer() reapplied = wc.enabled SharedLogStore.append( "🛡️ [webContent] reapply nach sync: enabled=\(wc.enabled) count=\(wc.count) region=\(wc.region)" ) } return [ "updated": true, "countries": countryKeyCount, "version": version, "reapplied": reapplied, ] } 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("⏳ [webContent] sync transient \(nsError.code), retry in \(delaySec)s") try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000) attempt += 1 continue } SharedLogStore.append("❌ [webContent] 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 ──────────────────────────────────────────────────────────────── /// Wendet den Layer-2-webContent-Filter an: Land bestimmen → Domain-Liste /// laden → `webContent.blockedByFilter = .auto`. Gemeinsame Logik für die /// explizite `applyWebContentFilter`-Function UND die FC-Aktivierung /// (`activateFamilyControls`/`activate`) — Layer 2 ist Teil des FC-Schutzes /// und läuft mit, sobald Family Controls aktiv ist. Voraussetzung: FC /// authorisiert (der Aufrufer prüft das). @available(iOS 16.0, *) private static func applyWebContentLayer() -> (enabled: Bool, count: Int, region: String) { var resolvedRegion = WEBCONTENT_FALLBACK_REGION if let region = Locale.current.region?.identifier, !region.isEmpty { resolvedRegion = region.uppercased() } else if let region = Locale.current.regionCode, !region.isEmpty { resolvedRegion = region.uppercased() } let domains = loadWebContentDomains(forRegion: resolvedRegion) guard !domains.isEmpty else { SharedLogStore.append("⚠️ [webContent] keine Domains für \(resolvedRegion)") return (false, 0, resolvedRegion) } // .auto(_, except:) blockt die gelisteten Domains PLUS systemseitig // Adult-Content. Hartlimit 50 Domains. let webDomains = Set(domains.prefix(WEBCONTENT_MAX_DOMAINS).map { WebDomain(domain: $0) }) let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) store.webContent.blockedByFilter = .auto(webDomains, except: []) SharedLogStore.append("🛡️ [webContent] blockedByFilter=.auto — \(webDomains.count) Domains (\(resolvedRegion))") return (true, webDomains.count, resolvedRegion) } // ── Packet-Tunnel (Layer 1) ────────────────────────────────────────────── /// Lesbarer Name eines NEVPNStatus — fürs Logging und das JS-Ergebnis. private static func tunnelStatusName(_ s: NEVPNStatus) -> String { switch s { case .invalid: return "invalid" case .disconnected: return "disconnected" case .connecting: return "connecting" case .connected: return "connected" case .reasserting: return "reasserting" case .disconnecting: return "disconnecting" @unknown default: return "unknown(\(s.rawValue))" } } /// Findet unter allen geladenen Tunnel-Managern den ReBreak-Tunnel /// (identifiziert über die `providerBundleIdentifier`). private static func findRebreakTunnel( in managers: [NETunnelProviderManager] ) -> NETunnelProviderManager? { return managers.first { manager in guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol else { return false } return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID } } /// Lädt den bestehenden ReBreak-Tunnel-Manager oder erstellt einen neuen, /// vollständig konfigurierten Manager (Protocol + On-Demand-Regel). /// /// On-Demand (`isOnDemandEnabled`): der Tunnel fährt nach Netzwerk-Wechseln /// automatisch wieder hoch (Self-Healing-Friction — KEIN Hard-Lock; der User /// kann On-Demand in den iOS-Settings abschalten). User-Entscheidung: an. private static func loadOrCreateTunnelManager() async throws -> NETunnelProviderManager { let managers = try await NETunnelProviderManager.loadAllFromPreferences() let manager = findRebreakTunnel(in: managers) ?? NETunnelProviderManager() // Protocol-Konfiguration (idempotent — bei jedem Aufruf frisch gesetzt). let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = PACKET_TUNNEL_BUNDLE_ID // `serverAddress` ist ein Pflichtfeld, wird bei einem rein lokalen // DNS-Sinkhole aber nie real kontaktiert — nur als Settings-UI-Label. proto.serverAddress = "ReBreak DNS-Filter (lokal)" manager.protocolConfiguration = proto manager.localizedDescription = PACKET_TUNNEL_DESCRIPTION manager.isEnabled = true // On-Demand: Tunnel nach Netzwerk-Events selbst hochfahren. let connectRule = NEOnDemandRuleConnect() connectRule.interfaceTypeMatch = .any manager.onDemandRules = [connectRule] manager.isOnDemandEnabled = true return manager } /// Lädt die kuratierte Gambling-Domain-Liste für ein Land — **cache-first**. /// /// 1. Zuerst die per `syncWebContentDomains` vom Backend geholte /// `webcontent-domains.json` aus dem App-Group-Container lesen. /// 2. Nur wenn die fehlt / nicht parsebar ist / das Land nicht enthält → /// Fallback auf die gebündelte `gambling-domains.json` im /// RebreakProtectionResources.bundle (Offline-Seed). /// /// Beide Quellen haben dieselbe Struktur (`{ "DE": ["..."], ... }` plus /// `_comment`/`_meta`-Felder, die ignoriert werden) und werden über /// `parseDomainsJSON` identisch geparst. Liefert [] wenn keine der beiden /// Quellen das Land listet — der Aufrufer behandelt das als /// no_domains_for_region. private static func loadWebContentDomains(forRegion region: String) -> [String] { // ── 1) Cache-first: App-Group-webcontent-domains.json ────────────────── if let appGroupURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) { let cacheURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME) if let domains = parseDomainsJSON(at: cacheURL, forRegion: region) { SharedLogStore.append( "🛡️ [loadWebContentDomains] cache-hit (\(domains.count) Domains, \(region.uppercased()))") return domains } } // ── 2) Fallback: gebündelte gambling-domains.json (Offline-Seed) ─────── if let url = bundledWebContentDomainsURL(), let domains = parseDomainsJSON(at: url, forRegion: region) { SharedLogStore.append( "🛡️ [loadWebContentDomains] bundle-fallback (\(domains.count) Domains, \(region.uppercased()))") return domains } SharedLogStore.append( "❌ [loadWebContentDomains] keine Domains für \(region.uppercased()) (cache+bundle leer)") return [] } /// Löst die URL der gebündelten `gambling-domains.json` auf. Das JSON wird /// von der Podspec als RebreakProtectionResources.bundle ins App-Bundle /// gepackt; je nach Build-Konfig liegt es im Pod-Bundle oder flach im /// Main-Bundle. private static func bundledWebContentDomainsURL() -> URL? { 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") } return jsonURL } /// Parst eine Domain-Liste-JSON-Datei (Cache ODER Bundle — identische /// Struktur) und gibt die normalisierte Domain-Liste für `region` zurück. /// Liefert `nil` wenn die Datei fehlt, nicht parsebar ist oder das Land /// nicht enthält — der Aufrufer behandelt `nil` als „diese Quelle leer, /// nächste versuchen". private static func parseDomainsJSON(at url: URL, forRegion region: String) -> [String]? { guard FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let domains = root[region.uppercased()] as? [String] else { return nil } // Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert, // hart auf das Apple-Limit (WEBCONTENT_MAX_DOMAINS) gekappt. var seen = Set() 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 } } // Ein leeres Array ist eine gültige Antwort dieser Quelle (Land gelistet, // aber 0 Domains) — der Aufrufer entscheidet dann no_domains_for_region. 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())) } } }