Layer 1 (PacketTunnel-DNS-Sinkhole) blockte nichts: `startTunnel` lud 0 Hashes, obwohl `blocklist.bin` 330k Hashes hatte. Ursache: `syncBlocklist` setzte `URLFileProtection.complete` — die Datei ist damit bei GESPERRTEM Gerät unlesbar. Der PacketTunnel wird OS-getrieben (on-demand) auch während Lock-Phasen neu gestartet → `open()` schlägt fehl → 0 Hashes → Layer 1 tot bis zum nächsten App-Sync. Beweis aus den Extension-Logs: `blocklist reloaded` (Darwin-Notif nach App-Sync, Gerät entsperrt) = 329k-330k Hashes; `startTunnel → blocklist geladen` = 0 — dieselbe Datei, dieselbe Path. Fix: `.complete` → `.completeUntilFirstUserAuthentication` (blocklist.bin + webContent-Cache). Bleibt at-rest verschlüsselt (DiGA-konform), ist aber nach dem ersten Entsperren seit Boot lesbar — die korrekte Klasse für eine Datei, die eine Network-Extension 24/7 lesen muss. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1364 lines
60 KiB
Swift
1364 lines
60 KiB
Swift
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: Datei verschlüsselt at-rest. WICHTIG:
|
||
// `.completeUntilFirstUserAuthentication`, NICHT `.complete` —
|
||
// die PacketTunnel-Extension muss `blocklist.bin` auch bei
|
||
// GESPERRTEM Gerät lesen können. `startTunnel` läuft OS-getrieben
|
||
// (on-demand) auch während Lock-Phasen; `.complete` macht die Datei
|
||
// dann unlesbar → `HashList.load()` liefert 0 Hashes → Layer 1
|
||
// blockt nichts, bis die App das nächste Mal synct.
|
||
// `.completeUntilFirstUserAuthentication` bleibt at-rest
|
||
// verschlüsselt, ist aber nach dem ersten Entsperren seit Boot
|
||
// lesbar — die korrekte Klasse für eine 24/7-NE-Datei.
|
||
try? (tmpURL as NSURL).setResourceValue(
|
||
URLFileProtection.completeUntilFirstUserAuthentication,
|
||
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) —
|
||
// `.completeUntilFirstUserAuthentication`, damit die Datei auch aus
|
||
// Hintergrund-Kontexten bei gesperrtem Gerät lesbar bleibt.
|
||
try? (tmpURL as NSURL).setResourceValue(
|
||
URLFileProtection.completeUntilFirstUserAuthentication,
|
||
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<String>()
|
||
var cleaned: [String] = []
|
||
for raw in domains {
|
||
let d = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
if d.isEmpty || seen.contains(d) { continue }
|
||
seen.insert(d)
|
||
cleaned.append(d)
|
||
if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
|
||
}
|
||
// 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()))
|
||
}
|
||
}
|
||
}
|