from __future__ import annotations import math import os import random from collections.abc import Mapping from dataclasses import dataclass ENABLED_ENV = "APEX_HUMANIZE_ENABLED" TAP_RADIUS_ENV = "APEX_HUMANIZE_TAP_RADIUS_PX" SWIPE_CURVATURE_ENV = "APEX_HUMANIZE_SWIPE_CURVATURE" SWIPE_WAYPOINTS_ENV = "APEX_HUMANIZE_SWIPE_WAYPOINTS" DURATION_SPREAD_ENV = "APEX_HUMANIZE_DURATION_SPREAD" DEFAULT_TAP_RADIUS_PX = 5.0 DEFAULT_SWIPE_CURVATURE = 0.15 DEFAULT_SWIPE_WAYPOINTS = 8 DEFAULT_DURATION_SPREAD = 0.15 @dataclass(frozen=True) class HumanizeConfig: enabled: bool = True tap_radius_px: float = DEFAULT_TAP_RADIUS_PX swipe_curvature: float = DEFAULT_SWIPE_CURVATURE swipe_waypoints: int = DEFAULT_SWIPE_WAYPOINTS duration_spread: float = DEFAULT_DURATION_SPREAD def load_humanize_config(env: Mapping[str, str] | None = None) -> HumanizeConfig: if env is None: values: Mapping[str, str] = os.environ else: values = env return HumanizeConfig( enabled=_parse_bool(values.get(ENABLED_ENV), default=True), tap_radius_px=_parse_float(values.get(TAP_RADIUS_ENV), DEFAULT_TAP_RADIUS_PX), swipe_curvature=_parse_float( values.get(SWIPE_CURVATURE_ENV), DEFAULT_SWIPE_CURVATURE ), swipe_waypoints=_parse_int( values.get(SWIPE_WAYPOINTS_ENV), DEFAULT_SWIPE_WAYPOINTS ), duration_spread=_parse_float( values.get(DURATION_SPREAD_ENV), DEFAULT_DURATION_SPREAD ), ) def _parse_bool(value: str | None, *, default: bool) -> bool: if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on", "enabled"} def _parse_float(value: str | None, default: float) -> float: if value is None: return default try: return float(value) except ValueError: return default def _parse_int(value: str | None, default: int) -> int: if value is None: return default try: return int(value) except ValueError: return default _rng: random.Random | None = None def get_rng() -> random.Random: global _rng if _rng is None: _rng = random.Random() return _rng def set_rng(rng: random.Random | None) -> None: """Inject a seeded rng (tests). Pass ``None`` to reset to the default.""" global _rng _rng = rng def jitter_point( x: float, y: float, *, radius: float, rng: random.Random ) -> tuple[float, float]: """Polar-Gaussian offset around the target. Magnitude is drawn from a folded Gaussian with sigma=radius/2, then capped at ``radius``. The angle is uniform on [0, 2*pi). This concentrates jittered points near the target rather than uniformly on the circle edge, matching how humans tap close to (but not exactly on) a button center. """ r = abs(rng.gauss(0.0, radius / 2.0)) r = min(r, radius) angle = rng.uniform(0, 2 * math.pi) dx = r * math.cos(angle) dy = r * math.sin(angle) # Guard against cos/sin FP drift pushing distance slightly past radius. fd = math.hypot(dx, dy) if fd > radius: scale = radius / fd dx *= scale dy *= scale # Final clamp: hypot of the returned offset may overshoot by ulps; tighten # to ``radius - 1e-10`` to keep ``<= radius`` after subtraction/hypot. fx = x + dx fy = y + dy fd_final = math.hypot(fx - x, fy - y) if fd_final > radius: scale = (radius - 1e-10) / fd_final fx = x + dx * scale fy = y + dy * scale return (fx, fy) def jitter_duration( value_ms: int, *, spread: float, rng: random.Random ) -> int: """Uniform jitter within ``value_ms * spread``; floored at 1ms.""" delta = value_ms * spread return max(1, int(value_ms + rng.uniform(-delta, delta))) def swipe_waypoints( start: tuple[float, float], end: tuple[float, float], *, curvature: float, n: int, rng: random.Random, ) -> list[tuple[float, float]]: """Return start + ``n`` interior + end points. Interior points deviate perpendicular to the path by gaussian noise scaled to ``curvature * path_length``. Degenerate (zero-length) path returns ``[start, end]``.""" sx, sy = start ex, ey = end dx = ex - sx dy = ey - sy length = math.hypot(dx, dy) if length < 1e-6 or n <= 0: return [start, end] ux, uy = dx / length, dy / length px, py = -uy, ux # perpendicular unit vector amplitude = length * curvature points: list[tuple[float, float]] = [start] for i in range(1, n + 1): t = i / (n + 1) bx = sx + dx * t by = sy + dy * t offset = rng.gauss(0.0, amplitude / 2.0) points.append((bx + px * offset, by + py * offset)) points.append(end) return points