## Duo-Style Onboarding (Foundation + alle Slides) Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom vorherigen Iteration. Slides leben unter `components/onboarding/slides/`. - Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar), LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar - Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me), DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors), Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall- Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate + PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1) - State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume- on-launch via slideFromStep(me.onboardingStep) - Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding - Backend Profile.onboardingStep enum extended: welcome | account | plan | pre_protection | done (+ legacy nickname/block) - Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch durch Protection-Slide für NEFilter/VPN-Aktivierung - Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary, Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis), Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions ## Cooldown Auto-Disable Race-Fix Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS- Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET auto-resolved beim ersten expired-Hit; zweiter Call in applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable nie aufgerufen. - useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen statt redundantem API-Call. Atomarer, race-frei. - AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein Race). - lib/protection.forceDisable: console.log für Debug-Visibility. ## iOS NEFilter Robust-Disable (Native) `removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings- UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern: 1. loadFromPreferences 2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon) 3. removeFromPreferences (Config-Eintrag aus Settings) Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in PermissionDeniedSheet's resetUrlFilter genutzt (analog). ## Family Controls jetzt immer aktiv Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert). `familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr). "Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist jetzt voll funktional auf iOS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
637 lines
25 KiB
Swift
637 lines
25 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
|
||
}
|
||
|
||
// ───────── resetUrlFilter: cached "denied" State löschen + frischer Dialog ─────────
|
||
//
|
||
// Apple's NEFilterManager cached den "permission denied"-State wenn User einmal
|
||
// "Nicht erlauben" getippt hat — danach zeigt iOS den System-Dialog beim erneuten
|
||
// saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences
|
||
// löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences
|
||
// als brandneuer Permission-Request behandelt wird → frischer System-Dialog.
|
||
//
|
||
// Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht
|
||
// zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery.
|
||
|
||
AsyncFunction("resetUrlFilter") { () async -> [String: Any] in
|
||
var error: String? = nil
|
||
var enabled = false
|
||
do {
|
||
let manager = NEFilterManager.shared()
|
||
SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...")
|
||
try await manager.loadFromPreferences()
|
||
|
||
// Best-effort remove — schlägt fehl wenn nichts existiert, das ist OK.
|
||
SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...")
|
||
do {
|
||
try await manager.removeFromPreferences()
|
||
} catch {
|
||
SharedLogStore.append("ℹ️ removeFromPreferences ignored: \(error.localizedDescription)")
|
||
}
|
||
|
||
// Frische Config setzen + neu speichern → iOS zeigt jetzt frischen Dialog.
|
||
let config = NEFilterProviderConfiguration()
|
||
config.filterBrowsers = true
|
||
config.filterSockets = false
|
||
manager.providerConfiguration = config
|
||
manager.localizedDescription = "Rebreak URL Filter"
|
||
manager.isEnabled = true
|
||
|
||
SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences (fresh System-Dialog)...")
|
||
try await manager.saveToPreferences()
|
||
enabled = manager.isEnabled
|
||
SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))")
|
||
} catch let e as NSError {
|
||
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
|
||
SharedLogStore.append("❌ resetUrlFilter failed: \(error!)")
|
||
}
|
||
var result: [String: Any] = ["enabled": enabled]
|
||
if let error = error { result["error"] = error }
|
||
return result
|
||
}
|
||
|
||
// ───────── activateFamilyControls: NUR FC + denyAppRemoval ─────────
|
||
|
||
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
|
||
var error: String? = nil
|
||
var enabled = false
|
||
if #available(iOS 16.0, *) {
|
||
do {
|
||
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
|
||
let authorized = AuthorizationCenter.shared.authorizationStatus == .approved
|
||
SharedLogStore.append("✅ FamilyControls authorized (\(authorized))")
|
||
if authorized {
|
||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||
store.application.denyAppRemoval = true
|
||
store.application.denyAppInstallation = false
|
||
let lockActive = (store.application.denyAppRemoval as? Bool) == true
|
||
enabled = lockActive
|
||
SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)")
|
||
if !lockActive {
|
||
error = "denyAppRemoval_not_active"
|
||
}
|
||
} else {
|
||
enabled = false
|
||
}
|
||
} catch let e as NSError {
|
||
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
|
||
SharedLogStore.append("❌ FamilyControls auth failed: \(error!)")
|
||
}
|
||
} else {
|
||
error = "iOS 16+ required for FamilyControls"
|
||
}
|
||
var result: [String: Any] = ["enabled": enabled]
|
||
if let error = error { result["error"] = error }
|
||
return result
|
||
}
|
||
|
||
// ───────── activate (legacy, alle Layer in einem Call) ─────────
|
||
|
||
AsyncFunction("activate") { () async -> [String: Any] in
|
||
var missingLayers: [String] = []
|
||
var errors: [String] = []
|
||
|
||
// 1) Family Controls Authorization (iOS 16+)
|
||
var familyControlsApproved = false
|
||
if #available(iOS 16.0, *) {
|
||
do {
|
||
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
|
||
familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved
|
||
SharedLogStore.append("✅ FamilyControls authorized")
|
||
} catch let error as NSError {
|
||
let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)"
|
||
SharedLogStore.append("❌ \(msg)")
|
||
errors.append(msg)
|
||
}
|
||
} else {
|
||
errors.append("iOS 16+ required for FamilyControls")
|
||
}
|
||
if !familyControlsApproved {
|
||
missingLayers.append("familyControls")
|
||
}
|
||
|
||
// 2) NEFilter aktivieren
|
||
var urlFilterOn = false
|
||
do {
|
||
let manager = NEFilterManager.shared()
|
||
SharedLogStore.append("📥 [activate] loadFromPreferences...")
|
||
try await manager.loadFromPreferences()
|
||
|
||
let config = NEFilterProviderConfiguration()
|
||
config.filterBrowsers = true
|
||
config.filterSockets = false
|
||
manager.providerConfiguration = config
|
||
manager.localizedDescription = "Rebreak URL Filter"
|
||
manager.isEnabled = true
|
||
|
||
SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...")
|
||
try await manager.saveToPreferences()
|
||
urlFilterOn = manager.isEnabled
|
||
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))")
|
||
} catch let error as NSError {
|
||
let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)"
|
||
SharedLogStore.append("❌ \(msg)")
|
||
errors.append(msg)
|
||
}
|
||
if !urlFilterOn {
|
||
missingLayers.append("urlFilter")
|
||
}
|
||
|
||
// 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved)
|
||
if #available(iOS 16.0, *), familyControlsApproved {
|
||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||
store.application.denyAppRemoval = true
|
||
store.application.denyAppInstallation = false
|
||
SharedLogStore.append("🔒 denyAppRemoval = true")
|
||
}
|
||
|
||
return [
|
||
"allLayersOn": missingLayers.isEmpty,
|
||
"missingLayers": missingLayers,
|
||
// Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt
|
||
"errors": errors,
|
||
]
|
||
}
|
||
|
||
// ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ─────────
|
||
|
||
AsyncFunction("disable") { () async -> [String: Any] in
|
||
// NEFilter — robuster Disable-Path:
|
||
// 1. loadFromPreferences (current config + isEnabled state lesen)
|
||
// 2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen +
|
||
// Settings.app-UI flippt sofort auf "deaktiviert")
|
||
// 3. removeFromPreferences (Config-Eintrag aus Settings entfernen)
|
||
//
|
||
// Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen
|
||
// (insb. iOS 18+) unzuverlässig — Settings-UI zeigt "Läuft..." obwohl der
|
||
// Provider beendet sein sollte. Erst isEnabled=false + save bringt das
|
||
// System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config
|
||
// löschen. Pattern aus Apple-Developer-Forums + eigene Empirie.
|
||
do {
|
||
let manager = NEFilterManager.shared()
|
||
try await manager.loadFromPreferences()
|
||
if manager.isEnabled {
|
||
manager.isEnabled = false
|
||
do {
|
||
try await manager.saveToPreferences()
|
||
SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)")
|
||
} catch {
|
||
SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
try await manager.removeFromPreferences()
|
||
SharedLogStore.append("✅ NEFilter disabled + removed from preferences")
|
||
} catch {
|
||
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
|
||
}
|
||
|
||
// ManagedSettings (löst denyAppRemoval)
|
||
if #available(iOS 16.0, *) {
|
||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||
store.clearAllSettings()
|
||
SharedLogStore.append("🔓 ManagedSettings cleared")
|
||
}
|
||
|
||
// Blocklist-Datei löschen
|
||
Self.clearBlocklistFile()
|
||
|
||
return ["allLayersOff": true]
|
||
}
|
||
|
||
// ───────── getDeviceState: aktueller Status aller Layer ─────────
|
||
|
||
AsyncFunction("getDeviceState") { () async -> [String: Any] in
|
||
// NEFilter
|
||
var urlFilter = false
|
||
do {
|
||
let manager = NEFilterManager.shared()
|
||
try await manager.loadFromPreferences()
|
||
urlFilter = manager.isEnabled
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
// FamilyControls
|
||
var familyControls = false
|
||
var appDeletionLock = false
|
||
if #available(iOS 16.0, *) {
|
||
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
|
||
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
|
||
appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
|
||
}
|
||
|
||
let count = Self.currentHashCount()
|
||
let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY)
|
||
|
||
return [
|
||
"urlFilter": urlFilter,
|
||
"familyControls": familyControls,
|
||
"appDeletionLock": appDeletionLock,
|
||
"blocklistCount": count,
|
||
"blocklistLastSyncAt": lastSync ?? NSNull(),
|
||
]
|
||
}
|
||
|
||
// ───────── syncBlocklist: download + atomic write + DarwinNotif ─────────
|
||
|
||
AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in
|
||
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
|
||
throw NSError(
|
||
domain: "RebreakProtection", code: 400,
|
||
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
|
||
)
|
||
}
|
||
guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else {
|
||
throw NSError(
|
||
domain: "RebreakProtection", code: 400,
|
||
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
|
||
)
|
||
}
|
||
|
||
// Retry mit Backoff (1s, 2s) — fängt iOS NECP-Race nach saveToPreferences
|
||
let maxAttempts = 3
|
||
var attempt = 1
|
||
var lastError: Error?
|
||
|
||
while attempt <= maxAttempts {
|
||
do {
|
||
var request = URLRequest(url: endpoint)
|
||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) {
|
||
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
|
||
}
|
||
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
throw NSError(
|
||
domain: "RebreakProtection", code: 500,
|
||
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
|
||
)
|
||
}
|
||
|
||
if httpResponse.statusCode == 304 {
|
||
SharedLogStore.append("📡 sync 304 (cached)")
|
||
return ["updated": false, "count": Self.currentHashCount()]
|
||
}
|
||
|
||
guard httpResponse.statusCode == 200 else {
|
||
throw NSError(
|
||
domain: "RebreakProtection", code: httpResponse.statusCode,
|
||
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
|
||
)
|
||
}
|
||
|
||
guard let appGroupURL = FileManager.default
|
||
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
|
||
else {
|
||
throw NSError(
|
||
domain: "RebreakProtection", code: 500,
|
||
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
|
||
)
|
||
}
|
||
|
||
let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME)
|
||
let tmpURL = finalURL.appendingPathExtension("tmp")
|
||
try data.write(to: tmpURL, options: .atomic)
|
||
|
||
// DiGA-Hardening
|
||
try? (tmpURL as NSURL).setResourceValue(
|
||
URLFileProtection.complete,
|
||
forKey: .fileProtectionKey
|
||
)
|
||
var mut = tmpURL
|
||
var rv = URLResourceValues()
|
||
rv.isExcludedFromBackup = true
|
||
try? mut.setResourceValues(rv)
|
||
|
||
// Atomic replace
|
||
_ = try? FileManager.default.removeItem(at: finalURL)
|
||
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
|
||
|
||
// ETag + lastSync persistieren
|
||
let defaults = UserDefaults(suiteName: APP_GROUP)
|
||
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
|
||
defaults?.set(newEtag, forKey: ETAG_KEY)
|
||
}
|
||
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY)
|
||
|
||
// Extension benachrichtigen (cross-process)
|
||
CFNotificationCenterPostNotification(
|
||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||
CFNotificationName(DARWIN_NOTIF as CFString),
|
||
nil, nil, true
|
||
)
|
||
|
||
let count = data.count / 8
|
||
let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? ""
|
||
SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))")
|
||
|
||
var result: [String: Any] = ["updated": true, "count": count]
|
||
if !plan.isEmpty { result["plan"] = plan }
|
||
return result
|
||
|
||
} catch {
|
||
lastError = error
|
||
let nsError = error as NSError
|
||
let isTransient = nsError.domain == NSURLErrorDomain && (
|
||
nsError.code == NSURLErrorNotConnectedToInternet ||
|
||
nsError.code == NSURLErrorCannotConnectToHost ||
|
||
nsError.code == NSURLErrorTimedOut ||
|
||
nsError.code == NSURLErrorNetworkConnectionLost ||
|
||
nsError.code == NSURLErrorCannotFindHost
|
||
)
|
||
if isTransient && attempt < maxAttempts {
|
||
let delaySec = UInt64(attempt)
|
||
SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s")
|
||
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
|
||
attempt += 1
|
||
continue
|
||
}
|
||
SharedLogStore.append("❌ sync failed: \(error.localizedDescription)")
|
||
throw error
|
||
}
|
||
}
|
||
|
||
throw lastError ?? NSError(
|
||
domain: "RebreakProtection", code: 500,
|
||
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
|
||
)
|
||
}
|
||
|
||
// ───────── runHealthProbe: hidden WKWebView gegen bet365 ─────────
|
||
|
||
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
|
||
let target = (opts?["target"] as? String) ?? "https://bet365.com"
|
||
let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0
|
||
guard let url = URL(string: target) else {
|
||
return [
|
||
"outcome": "offline",
|
||
"reason": "invalid_target",
|
||
"durationMs": 0,
|
||
"target": target,
|
||
]
|
||
}
|
||
|
||
return await withCheckedContinuation { continuation in
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else {
|
||
continuation.resume(returning: [
|
||
"outcome": "offline",
|
||
"reason": "module_gone",
|
||
"durationMs": 0,
|
||
"target": target,
|
||
])
|
||
return
|
||
}
|
||
|
||
// Vorherige Probe abbrechen
|
||
self.probeWebView?.stopLoading()
|
||
self.probeWebView?.navigationDelegate = nil
|
||
self.probeWebView = nil
|
||
self.probeDelegate = nil
|
||
|
||
let config = WKWebViewConfiguration()
|
||
config.websiteDataStore = .nonPersistent()
|
||
let webView = WKWebView(
|
||
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
|
||
configuration: config
|
||
)
|
||
|
||
let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in
|
||
self?.probeWebView?.stopLoading()
|
||
self?.probeWebView?.navigationDelegate = nil
|
||
self?.probeWebView = nil
|
||
self?.probeDelegate = nil
|
||
|
||
switch result {
|
||
case .blocked(let reason, let durationMs):
|
||
SharedLogStore.append("🛡️ probe BLOCKED: \(target) — \(reason) — \(durationMs)ms")
|
||
continuation.resume(returning: [
|
||
"outcome": "blocked", "reason": reason,
|
||
"durationMs": durationMs, "target": target,
|
||
])
|
||
case .loaded(let durationMs):
|
||
SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms")
|
||
continuation.resume(returning: [
|
||
"outcome": "loaded", "reason": "page_loaded",
|
||
"durationMs": durationMs, "target": target,
|
||
])
|
||
case .offline(let reason):
|
||
continuation.resume(returning: [
|
||
"outcome": "offline", "reason": reason,
|
||
"durationMs": 0, "target": target,
|
||
])
|
||
case .timeout:
|
||
continuation.resume(returning: [
|
||
"outcome": "timeout", "reason": "no_resolution",
|
||
"durationMs": Int(timeoutSec * 1000), "target": target,
|
||
])
|
||
}
|
||
}
|
||
|
||
self.probeWebView = webView
|
||
self.probeDelegate = delegate
|
||
webView.navigationDelegate = delegate
|
||
var request = URLRequest(url: url)
|
||
request.timeoutInterval = timeoutSec
|
||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||
webView.load(request)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────── openSystemSettings ─────────
|
||
|
||
AsyncFunction("openSystemSettings") { (target: String?) -> Void in
|
||
// Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische
|
||
// Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per
|
||
// URL-Scheme — Apple deny'd das seit iOS 13. Wir öffnen die App-Settings
|
||
// unserer App; der User navigiert von dort weiter.
|
||
DispatchQueue.main.async {
|
||
if let url = URL(string: UIApplication.openSettingsURLString),
|
||
UIApplication.shared.canOpenURL(url) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
private static func currentHashCount() -> Int {
|
||
guard let url = FileManager.default
|
||
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
|
||
.appendingPathComponent(BLOCKLIST_FILENAME),
|
||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||
let size = attrs[.size] as? Int
|
||
else { return 0 }
|
||
return size / 8
|
||
}
|
||
|
||
private static func clearBlocklistFile() {
|
||
if let url = FileManager.default
|
||
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
|
||
.appendingPathComponent(BLOCKLIST_FILENAME) {
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY)
|
||
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY)
|
||
CFNotificationCenterPostNotification(
|
||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||
CFNotificationName(DARWIN_NOTIF as CFString),
|
||
nil, nil, true
|
||
)
|
||
}
|
||
}
|
||
|
||
// ─── HealthProbeDelegate ──────────────────────────────────────────────────────
|
||
|
||
private final class HealthProbeDelegate: NSObject, WKNavigationDelegate {
|
||
enum Outcome {
|
||
case blocked(reason: String, durationMs: Int)
|
||
case loaded(durationMs: Int)
|
||
case offline(reason: String)
|
||
case timeout
|
||
}
|
||
|
||
private let onComplete: (Outcome) -> Void
|
||
private let startTime: Date
|
||
private var resolved = false
|
||
private var timeoutItem: DispatchWorkItem?
|
||
|
||
init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) {
|
||
self.onComplete = onComplete
|
||
self.startTime = Date()
|
||
super.init()
|
||
let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) }
|
||
self.timeoutItem = item
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item)
|
||
}
|
||
|
||
private func resolveOnce(_ outcome: Outcome) {
|
||
if resolved { return }
|
||
resolved = true
|
||
timeoutItem?.cancel()
|
||
timeoutItem = nil
|
||
onComplete(outcome)
|
||
}
|
||
|
||
private func durationMs() -> Int {
|
||
return Int(Date().timeIntervalSince(startTime) * 1000)
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
resolveOnce(.loaded(durationMs: durationMs()))
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||
let nsError = error as NSError
|
||
let reasonStr = "\(nsError.domain):\(nsError.code)"
|
||
if nsError.domain == NSURLErrorDomain &&
|
||
(nsError.code == NSURLErrorNotConnectedToInternet ||
|
||
nsError.code == NSURLErrorCannotFindHost ||
|
||
nsError.code == NSURLErrorTimedOut) {
|
||
resolveOnce(.offline(reason: reasonStr))
|
||
} else {
|
||
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
|
||
}
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||
let nsError = error as NSError
|
||
let reasonStr = "\(nsError.domain):\(nsError.code)"
|
||
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
|
||
resolveOnce(.offline(reason: reasonStr))
|
||
} else {
|
||
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
|
||
}
|
||
}
|
||
}
|