chahinebrini 1c9e67c256 feat(onboarding,protection,i18n): spotlight POC, arabic locale, NEFilter recovery
State of work before Duo-style onboarding pivot. Includes work that will be
partly reverted in the next commit (see refactor follow-up).

Onboarding (will be partly reverted):
- Custom Tooltip+Glow spotlight (components/OnboardingHint.tsx)
- Spotlight wiring in app/profile/edit.tsx (nickname-input glow + step-progress
  header, onSubmitEditing auto-save, save-handler routes to /(app)/blocker)
- Spotlight wiring in app/(app)/blocker.tsx (URL-filter LayerSwitchCard wrapped
  + auto-PATCH step='done' when filter activates)
- Routing-gate branches in (app)/_layout.tsx (welcome → /onboarding/welcome,
  nickname → /profile/edit)
- Debug-Reset-Toggle in /debug (welcome|nickname|block|done buttons + redirect)

Will stay (reused in Duo flow):
- Welcome-Screen app/onboarding/welcome.tsx (will become Slide 1)
- Avatar-fix in profile/edit (Dicebear seed stays stable while typing)

i18n + RTL:
- Arabic locale (locales/ar.json, full translation incl. onboarding keys)
- I18nManager.allowRTL(true) + applyRTL helper in stores/language.ts
- Language-Picker option for العربية in settings
- New keys: onboarding.welcome.*, step_progress, nickname_spotlight.*,
  block_spotlight.*, permission_denied.*, language.*, rtl_restart.* (de/en/fr/ar)

NEFilter Permission Recovery (iOS):
- Swift resetUrlFilter() — removeFromPreferences + fresh saveToPreferences to
  bypass iOS's cached denied-state (NEFilterErrorDomain code 5)
- TS module def + lib/protection.ts wrapper
- components/PermissionDeniedSheet.tsx — branded recovery sheet with retry +
  app-settings:// deep-link + fallback hint
- Wired in (app)/blocker.tsx handleActivateUrlFilter (code-5 detection)

Misc:
- Bug fix in onboarding/welcome.tsx: apiFetch body was double-stringified (sent
  as JSON string instead of object → 400 invalid_step)
- Bug fix in profile/edit.tsx: avatar preview Dicebear seed switched from live
  nickname (changed every keystroke) to stable me?.nickname

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 15:44:32 +02:00

618 lines
24 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

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

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

import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
private let LAST_SYNC_KEY = "blocklist_last_sync_at"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
private let MS_STORE_NAME = "rebreak.shield"
// Shared Log-Store
fileprivate enum SharedLogStore {
static let logKey = "url_filter_logs"
static let maxEntries = 200
static func append(_ message: String) {
NSLog("REBREAK_PROTECTION %@", message)
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
var logs = defaults.stringArray(forKey: logKey) ?? []
logs.append(entry)
if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
defaults.set(logs, forKey: logKey)
}
}
// 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
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()))
}
}
}