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() 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())) } } }