from __future__ import annotations import base64 import json from collections.abc import Callable from app.core.errors import EngineError, UnsupportedEngineError from app.core.factory import build_engine_manager from app.models import ( CapabilitySet, EngineKind, FreeTurnProfile, ProxyAccess, ProxySettings, RunMode, RuntimeRequest, RuntimeSnapshot, SignBoxProfile, Strategy, VpnSettings, ) class RuntimeController: def __init__(self, native_bridge=None) -> None: self.manager = build_engine_manager(native_bridge) self.signbox = SignBoxProfile(name="Основная", strategy=Strategy.BASIC) self.enhanced = SignBoxProfile(name="Усиленная", strategy=Strategy.ENHANCED) self.freeturn = FreeTurnProfile(name="Белые списки") self.proxy = ProxySettings() self.vpn = VpnSettings() self.mode = RunMode.VPN self.strategy = Strategy.BASIC self.engine_kind = EngineKind.SIGNBOX self.snapshot = RuntimeSnapshot() self._listeners: list[Callable[[], None]] = [] def add_listener(self, listener: Callable[[], None]) -> None: self._listeners.append(listener) def capabilities(self) -> CapabilitySet: return self.manager.capabilities(self.engine_kind) def _notify(self) -> None: for listener in tuple(self._listeners): listener() @property def active_profile_id(self) -> str: if self.mode is RunMode.PROXY and self.strategy is Strategy.BASIC: return self.signbox.id return self.enhanced.id if self.strategy is Strategy.ENHANCED else self.signbox.id async def connect(self) -> RuntimeSnapshot: if self.engine_kind is EngineKind.SIGNBOX: profile = self.enhanced if self.strategy is Strategy.ENHANCED else self.signbox request = RuntimeRequest( engine=EngineKind.SIGNBOX, mode=self.mode, profile_id=profile.id, proxy=self.proxy, vpn=self.vpn, engine_config=self._signbox_config(profile), ) else: request = RuntimeRequest( engine=EngineKind.FREETURN, mode=RunMode.PROXY, profile_id=self.freeturn.id, proxy=self.proxy, vpn=self.vpn, engine_config=self._freeturn_config(), ) self.snapshot = await self.manager.start(request) return self.snapshot async def stop(self) -> RuntimeSnapshot: self.snapshot = await self.manager.stop() return self.snapshot async def start_hotspot_share(self) -> RuntimeSnapshot: if self.engine_kind is not EngineKind.SIGNBOX: raise UnsupportedEngineError("Wi-Fi sharing is available for VPN strategy only") self.mode = RunMode.BOTH self.proxy.access = ProxyAccess.HOTSPOT return await self.connect() async def refresh(self) -> RuntimeSnapshot: self.snapshot = await self.manager.get_state() if self.snapshot.engine is not None: self.engine_kind = self.snapshot.engine if self.snapshot.mode is not None: self.mode = self.snapshot.mode self._notify() return self.snapshot def select_freeturn(self) -> None: self.engine_kind = EngineKind.FREETURN self.strategy = Strategy.BASIC self.mode = RunMode.PROXY def select_strategy(self, strategy: Strategy) -> None: self.engine_kind = EngineKind.SIGNBOX self.strategy = strategy def error_text(self, exc: Exception) -> str: if isinstance(exc, UnsupportedEngineError): return f"Недоступно: {exc}" if isinstance(exc, EngineError): return str(exc) return "Не удалось изменить состояние соединения" def _signbox_config(self, profile: SignBoxProfile) -> dict[str, object]: if profile.raw_config: try: raw = json.loads(profile.raw_config) if isinstance(raw, dict): return raw except json.JSONDecodeError: return {"raw_config": profile.raw_config} return { **profile.config, "strategy": profile.strategy.value, "mtu": profile.mtu, "split_tunneling": profile.split_tunneling, } def _freeturn_config(self) -> dict[str, object]: threads, per_credential = self.freeturn.stream_settings config = dict(self.freeturn.options) config_json = self._freeturn_config_json() if config_json: config["config_json"] = config_json config.update( { "key": self.freeturn.key, "threads": threads, "streams_per_cred": per_credential, } ) return config def _freeturn_config_json(self) -> str: raw = self.freeturn.options.get("config_json") if not isinstance(raw, str) or not raw.strip(): raw = self.freeturn.key.strip() if raw.startswith("freeturn://"): encoded = raw.removeprefix("freeturn://") try: padding = "=" * (-len(encoded) % 4) raw = base64.urlsafe_b64decode(encoded + padding).decode("utf-8") except (ValueError, UnicodeDecodeError): return "" try: parsed = json.loads(raw) except json.JSONDecodeError: return "" if not isinstance(parsed, dict): return "" parsed.setdefault("clientId", self.freeturn.id.replace("-", "")) threads, per_credential = self.freeturn.stream_settings turn = parsed.setdefault("turn", {}) if isinstance(turn, dict): turn["n"] = threads vk = parsed.get("vk") if isinstance(vk, dict): vk["streamsPerCred"] = per_credential return json.dumps(parsed, separators=(",", ":"))