chahinebrini 29bbf23405 feat(protection): iOS NEURLFilter-Spike + PIR-Server-Ops
NEURLFilter-Stack (iOS 26): Extension RebreakURLFilter -> URLFilterExtension
umbenannt, url-filter-provider-Entitlement, Bloom-Prefilter-Extension,
PIR-Client-Config (pirServerURL/pirAuthToken via Build-Env).
PIR-Server-Ops unter ops/pir-server/ (Dockerfile, build-and-deploy, Patches,
DTS-Report). backend/scripts/generate-pir-input.ts erzeugt die PIR-Datenbank.

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

781 lines
33 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

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

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

import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
private let LAST_SYNC_KEY = "blocklist_last_sync_at"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
private let MS_STORE_NAME = "rebreak.shield"
// 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)
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
// 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
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)
}
}
}
// 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
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()))
}
}
}