271 lines
9.5 KiB
TypeScript
271 lines
9.5 KiB
TypeScript
/**
|
|
* Protection orchestration layer (JS-side).
|
|
*
|
|
* Verbindet das native rebreak-protection-Modul (Device-Layer-State) mit
|
|
* dem Backend-Cooldown-API (`/api/cooldown/*` + `/api/protection/state`).
|
|
*
|
|
* Cooldown ist Backend-driven (JWT mit `cooldown_ends_at`-Claim, server-time
|
|
* = single source of truth gegen lokale-Uhr-Manipulation). Native-Modul
|
|
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
|
*/
|
|
import { Platform } from "react-native";
|
|
import RebreakProtection from "../modules/rebreak-protection";
|
|
import type {
|
|
ActivateResult,
|
|
DeviceLayers,
|
|
HealthProbeOpts,
|
|
HealthProbeResult,
|
|
SyncBlocklistOpts,
|
|
SyncBlocklistResult,
|
|
SystemSettingsTarget,
|
|
} from "../modules/rebreak-protection";
|
|
import { apiFetch } from "./api";
|
|
|
|
// ─── Public Types ──────────────────────────────────────────────────────────
|
|
|
|
export type ProtectionPhase =
|
|
| "inactive"
|
|
| "activating"
|
|
| "active"
|
|
| "cooldownPending"
|
|
| "cooldownActive"
|
|
| "recoveringFromBypass";
|
|
|
|
export type CooldownState = {
|
|
active: boolean;
|
|
endsAt: string | null;
|
|
remainingSeconds: number;
|
|
reason: string | null;
|
|
};
|
|
|
|
export type ProtectionState = {
|
|
phase: ProtectionPhase;
|
|
layers: DeviceLayers;
|
|
cooldown: CooldownState;
|
|
blocklistCount: number;
|
|
plan: "free" | "pro" | "legend";
|
|
};
|
|
|
|
// ─── Backend Response-Types ────────────────────────────────────────────────
|
|
|
|
type BackendCooldownStatus = {
|
|
active: boolean;
|
|
remainingSeconds: number;
|
|
cooldownEndsAt: string | null;
|
|
token: string | null;
|
|
canDisableProtection: boolean;
|
|
reason?: string;
|
|
};
|
|
|
|
// Matches actual response from `apps/rebreak/server/api/protection/state.get.ts`
|
|
// (apiFetch unwrapt das `data`-Feld bereits, daher hier nur die Inner-Shape).
|
|
type BackendProtectionState = {
|
|
protectionShouldBeActive: boolean;
|
|
cooldown: {
|
|
active: boolean;
|
|
remainingSeconds: number;
|
|
cooldownEndsAt: string | null;
|
|
};
|
|
plan: "free" | "pro" | "legend";
|
|
};
|
|
|
|
// ─── Public API ────────────────────────────────────────────────────────────
|
|
|
|
export const protection = {
|
|
// ─── Native-Calls (Device-Layer) ─────────────────────────────────────────
|
|
|
|
activate(): Promise<ActivateResult> {
|
|
return RebreakProtection.activate();
|
|
},
|
|
|
|
async activateUrlFilter(): Promise<{ enabled: boolean; error?: string }> {
|
|
if (Platform.OS === "android") {
|
|
// Android Layer-1 = VpnService (DNS-Filter). iOS-API erwartet hier
|
|
// {enabled, error?}, also Native-`activate()`-Result re-shapen.
|
|
const res = await RebreakProtection.activate();
|
|
const enabled = !res.missingLayers.includes("vpn");
|
|
return enabled ? { enabled: true } : { enabled: false, error: res.errors?.[0] };
|
|
}
|
|
return RebreakProtection.activateUrlFilter();
|
|
},
|
|
|
|
async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> {
|
|
if (Platform.OS === "android") {
|
|
// Android Layer-2 = AccessibilityService (Browser-URL-Filter) + Tamper-Lock.
|
|
// Two-step UX:
|
|
// (1) A11y nicht aktiv → Settings öffnen, return {enabled:false} mit
|
|
// Marker-Error. UI fragt nach Return den State neu ab und tappt
|
|
// erneut auf "App lock" → wir landen in Step (2).
|
|
// (2) A11y aktiv → tamperLock armen → return {enabled:true}.
|
|
const a11y = await RebreakProtection.isAccessibilityEnabled();
|
|
if (!a11y.enabled) {
|
|
await RebreakProtection.openAccessibilitySettings();
|
|
return { enabled: false, error: "accessibility_pending" };
|
|
}
|
|
try {
|
|
await RebreakProtection.armTamperLock();
|
|
return { enabled: true };
|
|
} catch (e: any) {
|
|
return {
|
|
enabled: false,
|
|
error: e?.message ?? "tamper_lock_failed",
|
|
};
|
|
}
|
|
}
|
|
return RebreakProtection.activateFamilyControls();
|
|
},
|
|
|
|
/** Schaltet alle Layer ab. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
|
|
forceDisable() {
|
|
return RebreakProtection.disable();
|
|
},
|
|
|
|
getDeviceState(): Promise<DeviceLayers> {
|
|
return RebreakProtection.getDeviceState();
|
|
},
|
|
|
|
syncBlocklist(opts: SyncBlocklistOpts): Promise<SyncBlocklistResult> {
|
|
return RebreakProtection.syncBlocklist(opts);
|
|
},
|
|
|
|
runHealthProbe(opts?: HealthProbeOpts): Promise<HealthProbeResult> {
|
|
return RebreakProtection.runHealthProbe(opts);
|
|
},
|
|
|
|
openSystemSettings(target?: SystemSettingsTarget): Promise<void> {
|
|
return RebreakProtection.openSystemSettings(target);
|
|
},
|
|
|
|
addLayerChangeListener(cb: (layers: DeviceLayers) => void) {
|
|
return RebreakProtection.addListener("onLayerChange", cb);
|
|
},
|
|
|
|
// ─── Backend-Cooldown ────────────────────────────────────────────────────
|
|
|
|
/** Startet 24h Cooldown. Schutz BLEIBT aktiv, kann erst nach Ablauf disabled werden. */
|
|
async requestDeactivation(
|
|
reason?: string,
|
|
): Promise<{ cooldownEndsAt: string }> {
|
|
const res = await apiFetch<{
|
|
cooldownEndsAt: string;
|
|
token: string;
|
|
remainingSeconds: number;
|
|
}>("/api/cooldown/request", { method: "POST", body: { reason } });
|
|
return { cooldownEndsAt: res.cooldownEndsAt };
|
|
},
|
|
|
|
/** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */
|
|
async cancelDeactivation(): Promise<{ cancelled: boolean }> {
|
|
const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", {
|
|
method: "POST",
|
|
body: {},
|
|
});
|
|
return res;
|
|
},
|
|
|
|
async getCooldownStatus(): Promise<CooldownState> {
|
|
try {
|
|
const res = await apiFetch<BackendCooldownStatus>("/api/cooldown/status");
|
|
return {
|
|
active: res.active,
|
|
endsAt: res.cooldownEndsAt,
|
|
remainingSeconds: res.remainingSeconds,
|
|
reason: res.reason ?? null,
|
|
};
|
|
} catch {
|
|
// Offline / Backend down → konservativ: kein Cooldown angenommen
|
|
return { active: false, endsAt: null, remainingSeconds: 0, reason: null };
|
|
}
|
|
},
|
|
|
|
async getBackendProtectionState(): Promise<BackendProtectionState | null> {
|
|
try {
|
|
return await apiFetch<BackendProtectionState>("/api/protection/state");
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// ─── Combined State (für UI) ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Holt nativen Device-State + Backend-Cooldown parallel und merged.
|
|
* Phase-Berechnung folgt der State-Machine im Plan.
|
|
*/
|
|
async getCombinedState(): Promise<ProtectionState> {
|
|
const [layers, cooldown, backend] = await Promise.all([
|
|
this.getDeviceState(),
|
|
this.getCooldownStatus(),
|
|
this.getBackendProtectionState(),
|
|
]);
|
|
|
|
const allLayersOn = isAllLayersOn(layers);
|
|
const iosLockActive =
|
|
layers.appDeletionLock ?? layers.familyControls ?? false;
|
|
const phase: ProtectionPhase = cooldown.active
|
|
? "cooldownActive"
|
|
: backend?.protectionShouldBeActive === true &&
|
|
layers.urlFilter === true &&
|
|
iosLockActive !== true
|
|
? "recoveringFromBypass"
|
|
: allLayersOn
|
|
? "active"
|
|
: "inactive";
|
|
|
|
return {
|
|
phase,
|
|
layers,
|
|
cooldown,
|
|
blocklistCount: layers.blocklistCount,
|
|
plan: backend?.plan ?? "free",
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Wenn ein Cooldown TATSÄCHLICH gelaufen ist und jetzt elapsed → native disable.
|
|
*
|
|
* Defensiv: prüft `cooldownEndsAt` (heißt es gab einen Cooldown) UND
|
|
* `remainingSeconds <= 0` (heißt er ist abgelaufen) UND `canDisableProtection`.
|
|
* Backend kann `canDisableProtection: true` auch im initial-state geben;
|
|
* der `cooldownEndsAt`-Check verhindert dann False-Positives.
|
|
*/
|
|
async applyCooldownDisableIfElapsed(): Promise<boolean> {
|
|
const status = await apiFetch<BackendCooldownStatus>(
|
|
"/api/cooldown/status",
|
|
).catch(() => null);
|
|
if (!status) return false;
|
|
if (!status.canDisableProtection) return false;
|
|
if (!status.cooldownEndsAt) return false; // nie ein Cooldown gewesen
|
|
if (status.remainingSeconds > 0) return false; // Cooldown noch nicht abgelaufen
|
|
await this.forceDisable();
|
|
return true;
|
|
},
|
|
};
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
export function isAllLayersOn(layers: DeviceLayers): boolean {
|
|
// iOS: urlFilter + appDeletionLock (fallback: familyControls für ältere Builds).
|
|
// Android: vpn + accessibility (+ tamperLock optional).
|
|
if (
|
|
layers.urlFilter !== undefined ||
|
|
layers.familyControls !== undefined ||
|
|
layers.appDeletionLock !== undefined
|
|
) {
|
|
const lockLayer = layers.appDeletionLock ?? layers.familyControls;
|
|
return layers.urlFilter === true && lockLayer === true;
|
|
}
|
|
if (layers.vpn !== undefined || layers.accessibility !== undefined) {
|
|
return layers.vpn === true && layers.accessibility === true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function formatCooldownRemaining(seconds: number): string {
|
|
if (seconds <= 0) return "00:00:00";
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":");
|
|
}
|