chahinebrini a80cc8b08d fix(rebreak-native): track custom native module source (was swallowed by .gitignore)
apps/rebreak-native/.gitignore had bare `ios/` + `android/` patterns meant for the
Expo-prebuild output dirs — but with no leading slash they also matched
modules/rebreak-protection/{android,ios}, so the entire custom expo native module
(RebreakProtectionModule.kt, RebreakAccessibilityService.kt, RebreakVpnService.kt,
the DNS filter, the iOS NEFilter extension, podspec, ...) was never tracked. A
fresh clone / CI / `git clean` would lose it.

Anchor the prebuild patterns (`/ios/`, `/android/`), keep ignoring the module's
build artifacts (build/, .cxx/, .gradle/, Pods/), and commit the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:22 +02:00

570 lines
22 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"
// 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
}
// 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
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEFilter disabled")
} 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()))
}
}
}