refactor(core): extract cross-platform proxy runtime from windows app

This commit is contained in:
Dark-Avery 2026-03-16 17:22:51 +03:00
parent ecc89d45d6
commit ec70188385
3 changed files with 310 additions and 115 deletions

170
proxy/app_runtime.py Normal file
View File

@ -0,0 +1,170 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Dict, Optional
import proxy.tg_ws_proxy as tg_ws_proxy
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
class ProxyAppRuntime:
def __init__(self, app_dir: Path,
default_config: Optional[dict] = None,
logger_name: str = "tg-ws-runtime",
on_error: Optional[Callable[[str], None]] = None,
parse_dc_ip_list: Optional[
Callable[[list[str]], Dict[int, str]]
] = None,
run_proxy: Optional[Callable[..., object]] = None,
thread_factory: Optional[Callable[..., object]] = None):
self.app_dir = Path(app_dir)
self.config_file = self.app_dir / "config.json"
self.log_file = self.app_dir / "proxy.log"
self.default_config = dict(default_config or DEFAULT_CONFIG)
self.log = logging.getLogger(logger_name)
self.on_error = on_error
self.parse_dc_ip_list = parse_dc_ip_list or tg_ws_proxy.parse_dc_ip_list
self.run_proxy = run_proxy or tg_ws_proxy._run
self.thread_factory = thread_factory or threading.Thread
self.config: dict = {}
self._proxy_thread = None
self._async_stop = None
def ensure_dirs(self):
self.app_dir.mkdir(parents=True, exist_ok=True)
def load_config(self) -> dict:
self.ensure_dirs()
if self.config_file.exists():
try:
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
for key, value in self.default_config.items():
data.setdefault(key, value)
self.config = data
return data
except Exception as exc:
self.log.warning("Failed to load config: %s", exc)
self.config = dict(self.default_config)
return dict(self.config)
def save_config(self, cfg: dict):
self.ensure_dirs()
self.config = dict(cfg)
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def reset_log_file(self):
if self.log_file.exists():
try:
self.log_file.unlink()
except Exception:
pass
def setup_logging(self, verbose: bool = False):
self.ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(self.log_file), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
root.addHandler(ch)
def prepare(self) -> dict:
cfg = self.load_config()
self.save_config(cfg)
return cfg
def _emit_error(self, text: str):
if self.on_error:
self.on_error(text)
def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str],
host: str = "127.0.0.1"):
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
self._async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc:
self.log.error("Proxy thread crashed: %s", exc)
if ("10048" in str(exc) or
"Address already in use" in str(exc)):
self._emit_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите.")
finally:
loop.close()
self._async_stop = None
def start_proxy(self, cfg: Optional[dict] = None) -> bool:
if self._proxy_thread and self._proxy_thread.is_alive():
self.log.info("Proxy already running")
return True
active_cfg = dict(cfg or self.config or self.default_config)
self.config = dict(active_cfg)
port = active_cfg.get("port", self.default_config["port"])
host = active_cfg.get("host", self.default_config["host"])
dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"])
try:
dc_opt = self.parse_dc_ip_list(dc_ip_list)
except ValueError as exc:
self.log.error("Bad config dc_ip: %s", exc)
self._emit_error("Ошибка конфигурации:\n%s" % exc)
return False
self.log.info("Starting proxy on %s:%d ...", host, port)
self._proxy_thread = self.thread_factory(
target=self._run_proxy_thread,
args=(port, dc_opt, host),
daemon=True,
name="proxy")
self._proxy_thread.start()
return True
def stop_proxy(self):
if self._async_stop:
loop, stop_ev = self._async_stop
loop.call_soon_threadsafe(stop_ev.set)
if self._proxy_thread:
self._proxy_thread.join(timeout=2)
self._proxy_thread = None
self.log.info("Proxy stopped")
def restart_proxy(self, delay_seconds: float = 0.3) -> bool:
self.log.info("Restarting proxy...")
self.stop_proxy()
time.sleep(delay_seconds)
return self.start_proxy()

121
tests/test_app_runtime.py Normal file
View File

@ -0,0 +1,121 @@
import json
import tempfile
import unittest
from pathlib import Path
from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime
class _FakeThread:
def __init__(self, target=None, args=(), daemon=None, name=None):
self.target = target
self.args = args
self.daemon = daemon
self.name = name
self.started = False
self.join_timeout = None
self._alive = False
def start(self):
self.started = True
self._alive = True
def is_alive(self):
return self._alive
def join(self, timeout=None):
self.join_timeout = timeout
self._alive = False
class ProxyAppRuntimeTests(unittest.TestCase):
def test_load_config_returns_defaults_when_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
runtime = ProxyAppRuntime(Path(tmpdir))
cfg = runtime.load_config()
self.assertEqual(cfg, DEFAULT_CONFIG)
def test_load_config_merges_defaults_into_saved_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
app_dir = Path(tmpdir)
config_path = app_dir / "config.json"
app_dir.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps({"port": 9050, "host": "127.0.0.2"}),
encoding="utf-8")
runtime = ProxyAppRuntime(app_dir)
cfg = runtime.load_config()
self.assertEqual(cfg["port"], 9050)
self.assertEqual(cfg["host"], "127.0.0.2")
self.assertEqual(cfg["dc_ip"], DEFAULT_CONFIG["dc_ip"])
self.assertEqual(cfg["verbose"], DEFAULT_CONFIG["verbose"])
def test_invalid_config_file_falls_back_to_defaults(self):
with tempfile.TemporaryDirectory() as tmpdir:
app_dir = Path(tmpdir)
app_dir.mkdir(parents=True, exist_ok=True)
(app_dir / "config.json").write_text("{broken", encoding="utf-8")
runtime = ProxyAppRuntime(app_dir)
cfg = runtime.load_config()
self.assertEqual(cfg, DEFAULT_CONFIG)
def test_start_proxy_starts_thread_with_parsed_dc_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
captured = {}
thread_holder = {}
def fake_parse(entries):
captured["dc_ip"] = list(entries)
return {2: "149.154.167.220"}
def fake_thread_factory(**kwargs):
thread = _FakeThread(**kwargs)
thread_holder["thread"] = thread
return thread
runtime = ProxyAppRuntime(
Path(tmpdir),
parse_dc_ip_list=fake_parse,
thread_factory=fake_thread_factory)
started = runtime.start_proxy(dict(DEFAULT_CONFIG))
self.assertTrue(started)
self.assertEqual(captured["dc_ip"], DEFAULT_CONFIG["dc_ip"])
self.assertTrue(thread_holder["thread"].started)
self.assertEqual(
thread_holder["thread"].args,
(DEFAULT_CONFIG["port"], {2: "149.154.167.220"},
DEFAULT_CONFIG["host"]))
def test_start_proxy_reports_bad_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
def fake_parse(entries):
raise ValueError("bad dc mapping")
runtime = ProxyAppRuntime(
Path(tmpdir),
parse_dc_ip_list=fake_parse,
on_error=errors.append)
started = runtime.start_proxy({
"host": "127.0.0.1",
"port": 1080,
"dc_ip": ["broken"],
"verbose": False,
})
self.assertFalse(started)
self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"])
if __name__ == "__main__":
unittest.main()

View File

@ -11,39 +11,33 @@ import time
import webbrowser import webbrowser
import pystray import pystray
import pyperclip import pyperclip
import asyncio as _asyncio
import customtkinter as ctk import customtkinter as ctk
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Optional
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
}
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None _lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
_runtime = ProxyAppRuntime(
APP_DIR,
default_config=DEFAULT_CONFIG,
logger_name="tg-ws-tray",
on_error=lambda text: _show_error(text),
)
CONFIG_FILE = _runtime.config_file
LOG_FILE = _runtime.log_file
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
@ -120,48 +114,19 @@ def _acquire_lock() -> bool:
def _ensure_dirs(): def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True) _runtime.ensure_dirs()
def load_config() -> dict: def load_config() -> dict:
_ensure_dirs() return _runtime.load_config()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict): def save_config(cfg: dict):
_ensure_dirs() _runtime.save_config(cfg)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False): def setup_logging(verbose: bool = False):
_ensure_dirs() _runtime.setup_logging(verbose)
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
root.addHandler(ch)
def _make_icon_image(size: int = 64): def _make_icon_image(size: int = 64):
@ -196,71 +161,16 @@ def _load_icon():
pass pass
return _make_icon_image() return _make_icon_image()
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
host: str = '127.0.0.1'):
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
_show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.")
finally:
loop.close()
_async_stop = None
def start_proxy(): def start_proxy():
global _proxy_thread, _config _runtime.start_proxy(_config)
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy(): def stop_proxy():
global _proxy_thread, _async_stop _runtime.stop_proxy()
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy(): def restart_proxy():
log.info("Restarting proxy...") _runtime.restart_proxy()
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
@ -642,14 +552,8 @@ def _build_menu():
def run_tray(): def run_tray():
global _tray_icon, _config global _tray_icon, _config
_config = load_config() _config = _runtime.prepare()
save_config(_config) _runtime.reset_log_file()
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False)) setup_logging(_config.get("verbose", False))
log.info("TG WS Proxy tray app starting") log.info("TG WS Proxy tray app starting")