Some checks failed
Windows project source guards / test (push) Failing after 31s
Движок разложен на части с одной задачей каждая: - endpoint: вид сервера (TCP / QUIC / WireGuard), хосты, порты, DNS; - strategies: каталоги winws2 и правило для выбранного сервера; - presets: файлы пресетов; - command: сборка командной строки winws2 (чистая функция); - manager: процесс winws2 с одним входом apply(план) и ревизиями — готовность только по started процесса нужной ревизии, таймаут 5 с. Склейка с подключением (~450 строк в контроллере, два наслоенных механизма «UDP-защиты» и «профиля сервера», кэш и прогрев DNS) заменена шлюзом application/server_bypass.py. Сохранены: DNS только в воркере, остановка туннеля перед сменой правила (кроме восстановления Hysteria), забор перед стартом ядра, fail-closed при потере winws2 (теперь и по таймауту перезапуска), маскировка IP сервера в логах до старта ядра; адреса серверов пула изучаются в фоне после подключения. Поведение: - у QUIC и WireGuard теперь свои стратегии; ключ zapret_target совместим, старые сборки после отката читают udp_* как раньше; - в каталогах вернулись 82 составные стратегии с ветками --payload (валидатор принимал только --lua-desync и молча их выбрасывал); - смена пресета под защищённой сессией идёт через переход: туннель останавливается, winws2 перезапускается, VPN подключается снова; - пропавший файл выбранного пресета — откат к Default, а не отказ; - правка работающего пресета перезапускает его; переименование больше не оставляет копию, занятое имя отклоняется; - настройки Zapret не откатываются при сохранении страницы «Настройки». Интерфейс: карточка состояния (работает/нет, пресет, одна кнопка, автозапуск), выбранный VPN-сервер с живым правилом, три строки видов серверов с подстраницей стратегии, пресеты — отдельной подстраницей. Объяснение — в окне «Как это б#&^ь работает?» с иллюстрацией.
120 lines
4.4 KiB
Python
120 lines
4.4 KiB
Python
"""Two-phase startup preparation. Workers never construct Qt widgets/managers."""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from pathlib import Path
|
||
from PyQt6.QtCore import QObject, QThread, pyqtSignal
|
||
from ..profiles.models import AppState, AppSettings
|
||
from ..profiles.storage import StateStorage, PassphraseRequired
|
||
from ..diagnostics.traffic_history import TrafficHistoryStorage
|
||
|
||
|
||
class StartupWorker(QThread):
|
||
early = pyqtSignal(object, bool)
|
||
loaded = pyqtSignal(object, object)
|
||
password_needed = pyqtSignal()
|
||
failed = pyqtSignal(str)
|
||
|
||
def __init__(self, storage, prepare=None, parent=None):
|
||
super().__init__(parent)
|
||
self.storage = storage
|
||
self.prepare = prepare
|
||
|
||
def run(self):
|
||
started = time.perf_counter()
|
||
payload = None
|
||
try:
|
||
if not hasattr(self.storage, "_startup_raw"):
|
||
self.storage._startup_raw = self.storage.state_file.read_text(encoding="utf-8") if self.storage.state_file.exists() else ""
|
||
payload = self.storage.load_payload(self.storage._startup_raw)
|
||
settings = AppSettings.from_dict(payload.get("settings") or {})
|
||
locked = bool((payload.get("security") or {}).get("enabled"))
|
||
self.early.emit(settings, locked)
|
||
if self.isInterruptionRequested():
|
||
return
|
||
if self.prepare:
|
||
self.prepare()
|
||
state = self.storage._normalize_state_paths(AppState.from_dict(payload))
|
||
history = TrafficHistoryStorage()
|
||
if not self.isInterruptionRequested():
|
||
self.loaded.emit(state, history)
|
||
except PassphraseRequired:
|
||
self.early.emit(AppSettings(), True)
|
||
self.password_needed.emit()
|
||
except Exception:
|
||
if payload is None and self.storage.passphrase:
|
||
self.password_needed.emit()
|
||
return
|
||
self.failed.emit("Не удалось загрузить данные приложения")
|
||
logging.getLogger("xray_fluent.bootstrap").error("Startup data preparation failed")
|
||
finally:
|
||
logging.getLogger("xray_fluent.bootstrap").info("startup_data_ms=%.1f", (time.perf_counter() - started) * 1000)
|
||
|
||
|
||
class StartupLoader(QObject):
|
||
early = pyqtSignal(object, bool)
|
||
loaded = pyqtSignal(object, object)
|
||
password_needed = pyqtSignal()
|
||
failed = pyqtSignal(str)
|
||
|
||
def __init__(self, parent=None, prepare=None):
|
||
super().__init__(parent)
|
||
self.storage = StateStorage()
|
||
self.prepare = prepare
|
||
self._workers = set()
|
||
self._closed = False
|
||
|
||
def start(self, passphrase=""):
|
||
if self._closed:
|
||
return
|
||
self.storage.passphrase = passphrase
|
||
worker = StartupWorker(self.storage, self.prepare, self)
|
||
self._workers.add(worker)
|
||
worker.early.connect(self.early)
|
||
worker.loaded.connect(self.loaded)
|
||
worker.password_needed.connect(self.password_needed)
|
||
worker.failed.connect(self.failed)
|
||
worker.finished.connect(lambda: self._finished(worker))
|
||
worker.start()
|
||
|
||
def _finished(self, worker):
|
||
self._workers.discard(worker)
|
||
worker.deleteLater()
|
||
|
||
def cancel(self):
|
||
self._closed = True
|
||
for worker in self._workers:
|
||
worker.requestInterruption()
|
||
|
||
def finish_shutdown(self):
|
||
self.cancel()
|
||
# Called after the GUI loop has exited: keep QThreads alive until completion.
|
||
for worker in tuple(self._workers):
|
||
worker.wait()
|
||
|
||
|
||
class MetadataWorker(QThread):
|
||
ready = pyqtSignal(object)
|
||
|
||
def __init__(self, xray_path, singbox_path, parent=None):
|
||
super().__init__(parent)
|
||
self.paths = (xray_path, singbox_path)
|
||
|
||
def run(self):
|
||
from ..engines.xray import get_xray_version
|
||
from ..engines.singbox.core_updater import installed_version
|
||
from ..engines.zapret import presets
|
||
result = {"xray": "", "singbox": "", "presets": []}
|
||
for key, call in (
|
||
("xray", lambda: get_xray_version(self.paths[0]) or ""),
|
||
("singbox", lambda: installed_version(Path(self.paths[1])) or ""),
|
||
("presets", presets.list_preset_infos),
|
||
):
|
||
if self.isInterruptionRequested():
|
||
return
|
||
try:
|
||
result[key] = call()
|
||
except Exception:
|
||
pass
|
||
self.ready.emit(result)
|