461 lines
13 KiB
Python
461 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import logging.handlers
|
||
import os
|
||
import socket as _socket
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, Optional, Tuple
|
||
|
||
import psutil
|
||
|
||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||
from proxy import __version__
|
||
from utils.default_config import default_tray_config
|
||
|
||
log = logging.getLogger("tg-ws-tray")
|
||
|
||
APP_NAME = "TgWsProxy"
|
||
|
||
|
||
def _app_dir() -> Path:
|
||
if sys.platform == "win32":
|
||
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
||
if sys.platform == "darwin":
|
||
return Path.home() / "Library" / "Application Support" / APP_NAME
|
||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
||
|
||
|
||
APP_DIR = _app_dir()
|
||
CONFIG_FILE = APP_DIR / "config.json"
|
||
LOG_FILE = APP_DIR / "proxy.log"
|
||
FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
|
||
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
|
||
|
||
DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
|
||
|
||
IS_FROZEN = bool(getattr(sys, "frozen", False))
|
||
|
||
|
||
def ensure_dirs() -> None:
|
||
APP_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
# single-instance lock
|
||
|
||
_lock_file_path: Optional[Path] = None
|
||
|
||
|
||
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
|
||
try:
|
||
lock_ct = float(meta.get("create_time", 0.0))
|
||
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
|
||
return False
|
||
except Exception:
|
||
return False
|
||
if IS_FROZEN:
|
||
return APP_NAME.lower() in proc.name().lower()
|
||
try:
|
||
for arg in proc.cmdline():
|
||
if script_hint in arg:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def acquire_lock(script_hint: str = "") -> bool:
|
||
global _lock_file_path
|
||
ensure_dirs()
|
||
for f in list(APP_DIR.glob("*.lock")):
|
||
try:
|
||
pid = int(f.stem)
|
||
except Exception:
|
||
f.unlink(missing_ok=True)
|
||
continue
|
||
meta: dict = {}
|
||
try:
|
||
raw = f.read_text(encoding="utf-8").strip()
|
||
if raw:
|
||
meta = json.loads(raw)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if _same_process(meta, psutil.Process(pid), script_hint):
|
||
return False
|
||
except Exception:
|
||
pass
|
||
f.unlink(missing_ok=True)
|
||
|
||
lock_file = APP_DIR / f"{os.getpid()}.lock"
|
||
try:
|
||
proc = psutil.Process(os.getpid())
|
||
lock_file.write_text(
|
||
json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception:
|
||
lock_file.touch()
|
||
_lock_file_path = lock_file
|
||
return True
|
||
|
||
|
||
def release_lock() -> None:
|
||
global _lock_file_path
|
||
if _lock_file_path:
|
||
try:
|
||
_lock_file_path.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
_lock_file_path = None
|
||
|
||
|
||
# config
|
||
|
||
def load_config() -> dict:
|
||
ensure_dirs()
|
||
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) -> None:
|
||
ensure_dirs()
|
||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||
|
||
|
||
# logging
|
||
|
||
_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
|
||
_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
|
||
|
||
|
||
def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
||
ensure_dirs()
|
||
level = logging.DEBUG if verbose else logging.INFO
|
||
root = logging.getLogger()
|
||
root.setLevel(level)
|
||
|
||
fh = logging.handlers.RotatingFileHandler(
|
||
str(LOG_FILE),
|
||
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
|
||
backupCount=0,
|
||
encoding="utf-8",
|
||
)
|
||
fh.setLevel(logging.DEBUG)
|
||
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||
root.addHandler(fh)
|
||
|
||
if not IS_FROZEN:
|
||
ch = logging.StreamHandler(sys.stdout)
|
||
ch.setLevel(level)
|
||
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
|
||
root.addHandler(ch)
|
||
|
||
|
||
# icon
|
||
|
||
def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
margin = 2
|
||
draw.ellipse([margin, margin, size - margin, size - margin], fill=color)
|
||
|
||
for path in _font_paths():
|
||
try:
|
||
font = ImageFont.truetype(path, size=int(size * 0.55))
|
||
break
|
||
except Exception:
|
||
continue
|
||
else:
|
||
font = ImageFont.load_default()
|
||
|
||
bbox = draw.textbbox((0, 0), "T", font=font)
|
||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||
draw.text(
|
||
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
|
||
"T",
|
||
fill=(255, 255, 255, 255),
|
||
font=font,
|
||
)
|
||
return img
|
||
|
||
|
||
def _font_paths():
|
||
if sys.platform == "win32":
|
||
return ["arial.ttf"]
|
||
if sys.platform == "darwin":
|
||
return ["/System/Library/Fonts/Helvetica.ttc"]
|
||
return [
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||
]
|
||
|
||
|
||
def load_icon():
|
||
from PIL import Image
|
||
|
||
icon_path = Path(__file__).parents[1] / "icon.ico"
|
||
if icon_path.exists():
|
||
try:
|
||
return Image.open(str(icon_path))
|
||
except Exception:
|
||
pass
|
||
return make_icon_image(64)
|
||
|
||
|
||
# proxy lifecycle
|
||
|
||
_proxy_thread: Optional[threading.Thread] = None
|
||
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
||
|
||
|
||
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
||
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(stop_event=stop_ev))
|
||
except Exception as exc:
|
||
log.error("Proxy thread crashed: %s", exc)
|
||
if "Address already in use" in str(exc) or "10048" in str(exc):
|
||
on_port_busy(
|
||
"Не удалось запустить прокси:\n"
|
||
"Порт уже используется другим приложением.\n\n"
|
||
"Закройте приложение, использующее этот порт, "
|
||
"или измените порт в настройках прокси и перезапустите."
|
||
)
|
||
finally:
|
||
loop.close()
|
||
_async_stop = None
|
||
|
||
|
||
def apply_proxy_config(cfg: dict) -> bool:
|
||
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||
try:
|
||
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
|
||
except ValueError as e:
|
||
log.error("Bad config dc_ip: %s", e)
|
||
return False
|
||
|
||
pc = tg_ws_proxy.proxy_config
|
||
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||
pc.dc_redirects = dc_redirects
|
||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||
return True
|
||
|
||
|
||
def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||
global _proxy_thread
|
||
if _proxy_thread and _proxy_thread.is_alive():
|
||
log.info("Proxy already running")
|
||
return
|
||
|
||
if not apply_proxy_config(cfg):
|
||
on_error("Ошибка конфигурации DC → IP.")
|
||
return
|
||
|
||
pc = tg_ws_proxy.proxy_config
|
||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||
_proxy_thread = threading.Thread(
|
||
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||
)
|
||
_proxy_thread.start()
|
||
|
||
|
||
def stop_proxy() -> None:
|
||
global _proxy_thread, _async_stop
|
||
if _async_stop:
|
||
loop, stop_ev = _async_stop
|
||
loop.call_soon_threadsafe(stop_ev.set)
|
||
if _proxy_thread:
|
||
_proxy_thread.join(timeout=5)
|
||
_proxy_thread = None
|
||
log.info("Proxy stopped")
|
||
|
||
|
||
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||
log.info("Restarting proxy...")
|
||
stop_proxy()
|
||
time.sleep(0.3)
|
||
start_proxy(cfg, on_error)
|
||
|
||
|
||
def tg_proxy_url(cfg: dict) -> str:
|
||
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||
link_host = tg_ws_proxy.get_link_host(host)
|
||
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||
|
||
|
||
_IPV6_WARNING = (
|
||
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
|
||
"Telegram может пытаться подключаться через IPv6, "
|
||
"что не поддерживается и может привести к ошибкам.\n\n"
|
||
"Если прокси не работает или в логах присутствуют ошибки, "
|
||
"связанные с попытками подключения по IPv6 - "
|
||
"попробуйте отключить в настройках прокси Telegram попытку соединения "
|
||
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
|
||
"в системе.\n\n"
|
||
"Это предупреждение будет показано только один раз."
|
||
)
|
||
|
||
|
||
def _has_ipv6() -> bool:
|
||
try:
|
||
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
||
ip = addr[4][0]
|
||
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
try:
|
||
s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
|
||
s.bind(("::1", 0))
|
||
s.close()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
||
ensure_dirs()
|
||
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
||
return
|
||
IPV6_WARN_MARKER.touch()
|
||
threading.Thread(
|
||
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
|
||
daemon=True,
|
||
).start()
|
||
|
||
|
||
# update check
|
||
|
||
def maybe_notify_update(
|
||
cfg: dict,
|
||
is_exiting: Callable[[], bool],
|
||
ask_open: Callable[[str, str], bool],
|
||
) -> None:
|
||
if not cfg.get("check_updates", True):
|
||
return
|
||
|
||
def _work():
|
||
time.sleep(1.5)
|
||
if is_exiting():
|
||
return
|
||
try:
|
||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||
import webbrowser
|
||
|
||
run_check(__version__)
|
||
st = get_status()
|
||
if not st.get("has_update"):
|
||
return
|
||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||
ver = st.get("latest") or "?"
|
||
if ask_open(
|
||
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
|
||
"TG WS Proxy — обновление",
|
||
):
|
||
webbrowser.open(url)
|
||
except Exception as exc:
|
||
log.debug("Update check failed: %s", exc)
|
||
|
||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||
|
||
|
||
# ctk thread (windows / linux)
|
||
|
||
_ctk_root: Any = None
|
||
_ctk_root_ready = threading.Event()
|
||
|
||
|
||
def ensure_ctk_thread(ctk: Any) -> bool:
|
||
global _ctk_root
|
||
if ctk is None:
|
||
return False
|
||
if _ctk_root_ready.is_set():
|
||
return True
|
||
|
||
def _run():
|
||
global _ctk_root
|
||
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||
|
||
install_tkinter_variable_del_guard()
|
||
apply_ctk_appearance(ctk)
|
||
_ctk_root = ctk.CTk()
|
||
_ctk_root.withdraw()
|
||
_ctk_root_ready.set()
|
||
_ctk_root.mainloop()
|
||
|
||
threading.Thread(target=_run, daemon=True, name="ctk-root").start()
|
||
_ctk_root_ready.wait(timeout=5.0)
|
||
return _ctk_root is not None
|
||
|
||
|
||
def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
|
||
if _ctk_root is None:
|
||
return
|
||
done = threading.Event()
|
||
|
||
def _invoke():
|
||
try:
|
||
build_fn(done)
|
||
except Exception:
|
||
log.exception("CTk dialog failed")
|
||
done.set()
|
||
|
||
_ctk_root.after(0, _invoke)
|
||
done.wait()
|
||
import gc
|
||
gc.collect()
|
||
|
||
|
||
def quit_ctk() -> None:
|
||
if _ctk_root is not None:
|
||
try:
|
||
_ctk_root.after(0, _ctk_root.quit)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# common bootstrap
|
||
|
||
def bootstrap(cfg: dict) -> None:
|
||
save_config(cfg)
|
||
if LOG_FILE.exists():
|
||
try:
|
||
LOG_FILE.unlink()
|
||
except Exception:
|
||
pass
|
||
setup_logging(
|
||
cfg.get("verbose", False),
|
||
log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
|
||
)
|
||
log.info("TG WS Proxy версия %s starting", __version__)
|
||
log.info("Config: %s", cfg)
|
||
log.info("Log file: %s", LOG_FILE)
|