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" // ─── Shared Log-Store ───────────────────────────────────────────────────────── fileprivate enum SharedLogStore { static let logKey = "url_filter_logs" static let maxEntries = 200 static func append(_ message: String) { NSLog("REBREAK_PROTECTION %@", message) guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return } let timestamp = ISO8601DateFormatter().string(from: Date()) let entry = "[\(timestamp)] \(message)" var logs = defaults.stringArray(forKey: logKey) ?? [] logs.append(entry) if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) } defaults.set(logs, forKey: logKey) } } // ─── Module ─────────────────────────────────────────────────────────────────── /// Vereinheitlichtes ReBreak-Schutz-Modul. /// /// Wraps: /// - NEFilterDataProvider Konfiguration (URL-Filter Layer) /// - AuthorizationCenter + ManagedSettings (Family Controls — denyAppRemoval) /// - Blocklist-Sync mit Retry-Backoff /// - End-to-End Health-Probe via hidden WKWebView /// /// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt /// JS-seitig (`lib/protection.ts` → Backend-API). public class RebreakProtectionModule: Module { // Health-Probe State (UI-Thread) private var probeWebView: WKWebView? private var probeDelegate: HealthProbeDelegate? public func definition() -> ModuleDefinition { Name("RebreakProtection") Events("onLayerChange") // ───────── activate: Family Controls + NEFilter + denyAppRemoval ───────── // ───────── activateUrlFilter: NUR NEFilter ───────── AsyncFunction("activateUrlFilter") { () async -> [String: Any] in var error: String? = nil var enabled = false do { let manager = NEFilterManager.shared() SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...") try await manager.loadFromPreferences() let config = NEFilterProviderConfiguration() config.filterBrowsers = true config.filterSockets = false manager.providerConfiguration = config manager.localizedDescription = "Rebreak URL Filter" manager.isEnabled = true SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...") try await manager.saveToPreferences() enabled = manager.isEnabled SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))") } catch let e as NSError { error = "\(e.domain):\(e.code) \(e.localizedDescription)" SharedLogStore.append("❌ NEFilter enable failed: \(error!)") } var result: [String: Any] = ["enabled": enabled] if let error = error { result["error"] = error } return result } // ───────── resetUrlFilter: cached "denied" State löschen + frischer Dialog ───────── // // Apple's NEFilterManager cached den "permission denied"-State wenn User einmal // "Nicht erlauben" getippt hat — danach zeigt iOS den System-Dialog beim erneuten // saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences // löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences // als brandneuer Permission-Request behandelt wird → frischer System-Dialog. // // Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht // zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery. AsyncFunction("resetUrlFilter") { () async -> [String: Any] in var error: String? = nil var enabled = false do { let manager = NEFilterManager.shared() SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...") try await manager.loadFromPreferences() // Best-effort remove — schlägt fehl wenn nichts existiert, das ist OK. SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...") do { try await manager.removeFromPreferences() } catch { SharedLogStore.append("ℹ️ removeFromPreferences ignored: \(error.localizedDescription)") } // 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 SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences (fresh System-Dialog)...") try await manager.saveToPreferences() enabled = manager.isEnabled SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))") } 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, *) { do { try await AuthorizationCenter.shared.requestAuthorization(for: .individual) let authorized = AuthorizationCenter.shared.authorizationStatus == .approved SharedLogStore.append("✅ FamilyControls authorized (\(authorized))") 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 } } catch let e as NSError { error = "\(e.domain):\(e.code) \(e.localizedDescription)" SharedLogStore.append("❌ FamilyControls auth failed: \(error!)") } } else { error = "iOS 16+ required for FamilyControls" } var result: [String: Any] = ["enabled": enabled] if let error = error { result["error"] = error } return result } // ───────── activate (legacy, alle Layer in einem Call) ───────── AsyncFunction("activate") { () async -> [String: Any] in var missingLayers: [String] = [] var errors: [String] = [] // 1) Family Controls Authorization (iOS 16+) var familyControlsApproved = false if #available(iOS 16.0, *) { do { try await AuthorizationCenter.shared.requestAuthorization(for: .individual) familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved SharedLogStore.append("✅ FamilyControls authorized") } catch let error as NSError { let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)" SharedLogStore.append("❌ \(msg)") errors.append(msg) } } else { errors.append("iOS 16+ required for FamilyControls") } if !familyControlsApproved { missingLayers.append("familyControls") } // 2) NEFilter aktivieren var urlFilterOn = false do { let manager = NEFilterManager.shared() SharedLogStore.append("📥 [activate] loadFromPreferences...") try await manager.loadFromPreferences() let config = NEFilterProviderConfiguration() config.filterBrowsers = true config.filterSockets = false manager.providerConfiguration = config manager.localizedDescription = "Rebreak URL Filter" manager.isEnabled = true SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...") try await manager.saveToPreferences() urlFilterOn = manager.isEnabled SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))") } catch let error as NSError { let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)" SharedLogStore.append("❌ \(msg)") errors.append(msg) } if !urlFilterOn { missingLayers.append("urlFilter") } // 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved) if #available(iOS 16.0, *), familyControlsApproved { let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) store.application.denyAppRemoval = true store.application.denyAppInstallation = false SharedLogStore.append("🔒 denyAppRemoval = true") } return [ "allLayersOn": missingLayers.isEmpty, "missingLayers": missingLayers, // Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt "errors": errors, ] } // ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ───────── AsyncFunction("disable") { () async -> [String: Any] in // NEFilter — robuster Disable-Path: // 1. loadFromPreferences (current config + isEnabled state lesen) // 2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen + // Settings.app-UI flippt sofort auf "deaktiviert") // 3. removeFromPreferences (Config-Eintrag aus Settings entfernen) // // Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen // (insb. iOS 18+) unzuverlässig — Settings-UI zeigt "Läuft..." obwohl der // Provider beendet sein sollte. Erst isEnabled=false + save bringt das // System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config // löschen. Pattern aus Apple-Developer-Forums + eigene Empirie. do { let manager = NEFilterManager.shared() try await manager.loadFromPreferences() if manager.isEnabled { manager.isEnabled = false do { try await manager.saveToPreferences() SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)") } catch { SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)") } } try await manager.removeFromPreferences() SharedLogStore.append("✅ NEFilter disabled + removed from preferences") } catch { SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)") } // ManagedSettings (löst denyAppRemoval) if #available(iOS 16.0, *) { let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) store.clearAllSettings() SharedLogStore.append("🔓 ManagedSettings cleared") } // Blocklist-Datei löschen Self.clearBlocklistFile() return ["allLayersOff": true] } // ───────── getDeviceState: aktueller Status aller Layer ───────── AsyncFunction("getDeviceState") { () async -> [String: Any] in // NEFilter var urlFilter = false do { let manager = NEFilterManager.shared() try await manager.loadFromPreferences() urlFilter = manager.isEnabled } catch { // ignore } // FamilyControls var familyControls = false var appDeletionLock = false 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 } let count = Self.currentHashCount() let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY) return [ "urlFilter": urlFilter, "familyControls": familyControls, "appDeletionLock": appDeletionLock, "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) } } } } // ─── Helpers ──────────────────────────────────────────────────────────────── 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())) } } }