refactor(tray): общие модули utils/ui, README, LF в репозитории

Вынесена общая логика tray (пути, lock, конфиг, прокси, IPv6, обновления) в utils/tray_* и ui/tray_*; точки входа windows/linux/macos упрощены.

Добавлены .editorconfig и .gitattributes; в README кратко описана структура исходников.
This commit is contained in:
deexsed 2026-03-27 13:35:33 +03:00
parent 0d11062c92
commit 7db2691ae1
15 changed files with 1005 additions and 1075 deletions

11
.editorconfig Normal file
View File

@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_style = space
indent_size = 4

9
.gitattributes vendored Normal file
View File

@ -0,0 +1,9 @@
# Нормализация окончаний строк: LF в индексе (кроссплатформенные диффы).
* text=auto
*.py text eol=lf
*.md text eol=lf
*.json text eol=lf
*.toml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf

View File

@ -156,6 +156,12 @@ tg-ws-proxy-tray-macos = "macos:main"
tg-ws-proxy-tray-linux = "linux:main" tg-ws-proxy-tray-linux = "linux:main"
``` ```
## Структура исходников (tray)
Общая логика tray-приложений (пути данных, один экземпляр процесса, конфиг, логирование, поток с прокси, IPv6, фоновая проверка релизов) находится в `utils/tray_*.py`; вспомогательные части UI — в `ui/tray_*.py`. Точки входа по ОС: `windows.py`, `linux.py`, `macos.py`.
Для согласованных окончаний строк в репозитории используются `.editorconfig` и `.gitattributes` (LF).
## Настройка Telegram Desktop ## Настройка Telegram Desktop
### Автоматически ### Автоматически

484
linux.py
View File

@ -1,27 +1,21 @@
from __future__ import annotations from __future__ import annotations
import asyncio as _asyncio
import json
import logging import logging
import logging.handlers
import os import os
import subprocess import subprocess
import sys import sys
import threading import threading
import webbrowser
import time import time
import webbrowser
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Optional
import customtkinter as ctk import customtkinter as ctk
import psutil
import pyperclip import pyperclip
import pystray import pystray
from PIL import Image, ImageDraw, ImageFont from PIL import Image
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_buttons,
install_tray_config_form, install_tray_config_form,
@ -37,294 +31,58 @@ from ui.ctk_theme import (
ctk_theme_for_platform, ctk_theme_for_platform,
main_content_frame, main_content_frame,
) )
from ui.tray_ctk import destroy_root_safely
from ui.tray_icons import load_ico_or_synthesize
from utils.default_config import default_tray_config
from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging
from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled
from utils.tray_lock import (
SingleInstanceLock,
frozen_match_app_name_contains,
make_same_process_checker,
)
from utils.tray_paths import APP_NAME, tray_paths_linux
from utils.tray_proxy_runner import ProxyThreadRunner
from utils.tray_updates import spawn_notify_update_async
APP_NAME = "TgWsProxy" PATHS = tray_paths_linux()
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME APP_DIR = PATHS.app_dir
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = PATHS.config_file
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = PATHS.log_file
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" FIRST_RUN_MARKER = PATHS.first_run_marker
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = PATHS.ipv6_warn_marker
DEFAULT_CONFIG = default_tray_config() DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None _tray_icon: Optional[object] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
_instance_lock = SingleInstanceLock(
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: PATHS.app_dir,
try: make_same_process_checker(
lock_ct = float(lock_meta.get("create_time", 0.0)) script_marker="linux.py",
proc_ct = float(proc.create_time()) frozen_match=frozen_match_app_name_contains(APP_NAME),
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: ),
return False log=log,
except Exception: )
return False
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict: def load_config() -> dict:
_ensure_dirs() return load_tray_config(PATHS, DEFAULT_CONFIG, log)
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) -> None:
_ensure_dirs() save_tray_config(PATHS, 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, log_max_mb: float = 5): def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
_ensure_dirs() setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb)
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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 _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
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=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55)
)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _apply_linux_ctk_window_icon(root) -> None:
"""PhotoImage храним на root — иначе GC может убрать картинку до закрытия окна."""
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
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 "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
_proxy_thread.start()
def stop_proxy():
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=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
import tkinter as _tk import tkinter as _tk
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
@ -334,7 +92,7 @@ def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
root.destroy() root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"): def _show_info(text: str, title: str = "TG WS Proxy") -> None:
import tkinter as _tk import tkinter as _tk
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
@ -359,31 +117,60 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
return bool(r) return bool(r)
def _maybe_notify_update_async(): _proxy_runner = ProxyThreadRunner(
def _work(): default_config=DEFAULT_CONFIG,
time.sleep(1.5) get_config=lambda: _config,
if _exiting: log=log,
return show_error=_show_error,
if not _config.get("check_updates", True): join_timeout=2.0,
return warn_on_join_stuck=False,
try: treat_win_error_10048_as_port_in_use=False,
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check )
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 "?"
text = (
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?"
)
if _ask_yes_no_dialog(text, "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()
def start_proxy() -> None:
_proxy_runner.start()
def stop_proxy() -> None:
_proxy_runner.stop()
def restart_proxy() -> None:
_proxy_runner.restart()
def _load_icon():
assets = Path(__file__).parent
return load_ico_or_synthesize(
assets / "icon.ico",
[
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
],
)
def _apply_linux_ctk_window_icon(root) -> None:
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
def _maybe_notify_update_async() -> None:
spawn_notify_update_async(
get_config=lambda: _config,
exiting=lambda: _exiting,
ask_open_release=lambda ver, _url: _ask_yes_no_dialog(
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
),
log=log,
)
def _on_open_in_telegram(icon=None, item=None): def _on_open_in_telegram(icon=None, item=None):
@ -436,13 +223,18 @@ def _edit_config_dialog():
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form( widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG, ctk,
scroll,
theme,
cfg,
DEFAULT_CONFIG,
show_autostart=False, show_autostart=False,
) )
def on_save(): def on_save():
merged = validate_config_form( merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False) widgets, DEFAULT_CONFIG, include_autostart=False
)
if isinstance(merged, str): if isinstance(merged, str):
_show_error(merged) _show_error(merged)
return return
@ -470,17 +262,13 @@ def _edit_config_dialog():
root.destroy() root.destroy()
install_tray_config_buttons( install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) ctk, footer, theme, on_save=on_save, on_cancel=on_cancel
)
try: try:
root.mainloop() root.mainloop()
finally: finally:
import tkinter as tk destroy_root_safely(root)
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None): def _on_open_logs(icon=None, item=None):
@ -522,7 +310,7 @@ def _on_exit(icon=None, item=None):
def _show_first_run(): def _show_first_run():
_ensure_dirs() PATHS.app_dir.mkdir(parents=True, exist_ok=True)
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
@ -552,44 +340,20 @@ def _show_first_run():
_on_open_in_telegram() _on_open_in_telegram()
populate_first_run_window( populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done) ctk, root, theme, host=host, port=port, on_done=on_done
)
try: try:
root.mainloop() root.mainloop()
finally: finally:
import tkinter as tk destroy_root_safely(root)
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning(): def _check_ipv6_warning():
_ensure_dirs() PATHS.app_dir.mkdir(parents=True, exist_ok=True)
if IPV6_WARN_MARKER.exists(): if IPV6_WARN_MARKER.exists():
return return
if not _has_ipv6_enabled(): if not has_ipv6_enabled("simple"):
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
@ -598,18 +362,7 @@ def _check_ipv6_warning():
def _show_ipv6_dialog(): def _show_ipv6_dialog():
_show_info( _show_info(IPV6_WARN_BODY_LONG, "TG WS Proxy")
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy",
)
def _build_menu(): def _build_menu():
@ -619,7 +372,9 @@ def _build_menu():
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True f"Открыть в Telegram ({host}:{port})",
_on_open_in_telegram,
default=True,
), ),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Перезапустить прокси", _on_restart),
@ -642,8 +397,10 @@ def run_tray():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False), setup_logging(
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) _config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s, tray app starting", __version__) log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config) log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
@ -666,7 +423,12 @@ def run_tray():
_check_ipv6_warning() _check_ipv6_warning()
icon_image = _load_icon() icon_image = _load_icon()
_tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu()) _tray_icon = pystray.Icon(
APP_NAME,
icon_image,
"TG WS Proxy",
menu=_build_menu(),
)
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@ -676,14 +438,14 @@ def run_tray():
def main(): def main():
if not _acquire_lock(): if not _instance_lock.acquire():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
finally: finally:
_release_lock() _instance_lock.release()
if __name__ == "__main__": if __name__ == "__main__":

534
macos.py
View File

@ -1,18 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import logging.handlers
import os import os
import psutil
import subprocess import subprocess
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import asyncio as _asyncio
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Optional
try: try:
import rumps import rumps
@ -32,152 +28,108 @@ except ImportError:
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging
from utils.tray_ipv6 import IPV6_WARN_BODY_MACOS, has_ipv6_enabled
from utils.tray_lock import (
SingleInstanceLock,
frozen_match_app_name_contains,
make_same_process_checker,
)
from utils.tray_paths import APP_NAME, tray_paths_macos
from utils.tray_proxy_runner import ProxyThreadRunner
from utils.tray_updates import spawn_notify_update_async
APP_NAME = "TgWsProxy" PATHS = tray_paths_macos()
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME APP_DIR = PATHS.app_dir
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = PATHS.config_file
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = PATHS.log_file
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" FIRST_RUN_MARKER = PATHS.first_run_marker
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = PATHS.ipv6_warn_marker
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = default_tray_config() DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_app: Optional[object] = None _app: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
_instance_lock = SingleInstanceLock(
# Single-instance lock PATHS.app_dir,
make_same_process_checker(
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: script_marker=None,
try: frozen_match=frozen_match_app_name_contains(APP_NAME),
lock_ct = float(lock_meta.get("create_time", 0.0)) ),
proc_ct = float(proc.create_time()) log=log,
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: )
return False
except Exception:
return False
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock(): def _ensure_dirs() -> None:
global _lock_file_path PATHS.app_dir.mkdir(parents=True, exist_ok=True)
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {"create_time": proc.create_time()}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
# Filesystem helpers
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict: def load_config() -> dict:
_ensure_dirs() return load_tray_config(PATHS, DEFAULT_CONFIG, log)
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) -> None:
_ensure_dirs() save_tray_config(PATHS, 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, log_max_mb: float = 5): def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
_ensure_dirs() setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb)
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE), def _escape_osascript_text(text: str) -> str:
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), return text.replace("\\", "\\\\").replace('"', '\\"')
backupCount=0,
encoding='utf-8',
def _osascript(script: str) -> str:
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
return r.stdout.strip()
def _show_error(text: str, title: str = "TG WS Proxy") -> None:
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon stop'
) )
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)
# Menubar icon def _show_info(text: str, title: str = "TG WS Proxy") -> None:
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon note'
)
_proxy_runner = ProxyThreadRunner(
default_config=DEFAULT_CONFIG,
get_config=lambda: _config,
log=log,
show_error=_show_error,
join_timeout=2.0,
warn_on_join_stuck=False,
treat_win_error_10048_as_port_in_use=False,
)
def start_proxy() -> None:
_proxy_runner.start()
def stop_proxy() -> None:
_proxy_runner.stop()
def restart_proxy() -> None:
_proxy_runner.restart()
def _make_menubar_icon(size: int = 44): def _make_menubar_icon(size: int = 44):
if Image is None: if Image is None:
@ -186,13 +138,16 @@ def _make_menubar_icon(size: int = 44):
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
margin = size // 11 margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin], draw.ellipse(
fill=(0, 0, 0, 255)) [margin, margin, size - margin, size - margin],
fill=(0, 0, 0, 255),
)
try: try:
font = ImageFont.truetype( font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Helvetica.ttc",
size=int(size * 0.55)) size=int(size * 0.55),
)
except Exception: except Exception:
font = ImageFont.load_default() font = ImageFont.load_default()
@ -203,8 +158,8 @@ def _make_menubar_icon(size: int = 44):
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img return img
# Generate menubar icon PNG if it does not exist.
def _ensure_menubar_icon(): def _ensure_menubar_icon() -> None:
if MENUBAR_ICON_PATH.exists(): if MENUBAR_ICON_PATH.exists():
return return
_ensure_dirs() _ensure_dirs()
@ -213,51 +168,26 @@ def _ensure_menubar_icon():
img.save(str(MENUBAR_ICON_PATH), "PNG") img.save(str(MENUBAR_ICON_PATH), "PNG")
# Native macOS dialogs
def _escape_osascript_text(text: str) -> str:
return text.replace('\\', '\\\\').replace('"', '\\"')
def _osascript(script: str) -> str:
r = subprocess.run(
['osascript', '-e', script],
capture_output=True, text=True)
return r.stdout.strip()
def _show_error(text: str, title: str = "TG WS Proxy"):
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon stop')
def _show_info(text: str, title: str = "TG WS Proxy"):
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon note')
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
result = _ask_yes_no_close(text, title) result = _ask_yes_no_close(text, title)
return result is True return result is True
def _ask_yes_no_close(text: str, def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]:
title: str = "TG WS Proxy") -> Optional[bool]:
text_esc = _escape_osascript_text(text) text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title) title_esc = _escape_osascript_text(title)
r = subprocess.run( r = subprocess.run(
['osascript', '-e', [
f'button returned of (display dialog "{text_esc}" ' "osascript",
f'with title "{title_esc}" ' "-e",
f'buttons {{"Закрыть", "Нет", "Да"}} ' f'button returned of (display dialog "{text_esc}" '
f'default button "Да" cancel button "Закрыть" with icon note)'], f'with title "{title_esc}" '
capture_output=True, text=True) f'buttons {{"Закрыть", "Нет", "Да"}} '
f'default button "Да" cancel button "Закрыть" with icon note)',
],
capture_output=True,
text=True,
)
if r.returncode != 0: if r.returncode != 0:
return None return None
@ -269,93 +199,13 @@ def _ask_yes_no_close(text: str,
return None return None
# Proxy lifecycle
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 "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите.")
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy():
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=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
# Menu callbacks
def _on_open_in_telegram(_=None): def _on_open_in_telegram(_=None):
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}" url = f"tg://socks?server={host}&port={port}"
log.info("Opening %s", url) log.info("Opening %s", url)
try: try:
result = subprocess.call(['open', url]) result = subprocess.call(["open", url])
if result != 0: if result != 0:
raise RuntimeError("open command failed") raise RuntimeError("open command failed")
except Exception: except Exception:
@ -369,11 +219,11 @@ def _on_open_in_telegram(_=None):
if pyperclip: if pyperclip:
pyperclip.copy(url) pyperclip.copy(url)
else: else:
subprocess.run(['pbcopy'], input=url.encode(), subprocess.run(["pbcopy"], input=url.encode(), check=True)
check=True)
_show_info( _show_info(
"Не удалось открыть Telegram автоматически.\n\n" "Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена:\n{url}") f"Ссылка скопирована в буфер обмена:\n{url}"
)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
@ -393,24 +243,30 @@ def _on_restart(_=None):
def _on_open_logs(_=None): def _on_open_logs(_=None):
log.info("Opening log file: %s", LOG_FILE) log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists(): if LOG_FILE.exists():
subprocess.call(['open', str(LOG_FILE)]) subprocess.call(["open", str(LOG_FILE)])
else: else:
_show_info("Файл логов ещё не создан.") _show_info("Файл логов ещё не создан.")
# Show a native text input dialog. Returns None if cancelled.
def _osascript_input(prompt: str, default: str, def _osascript_input(
title: str = "TG WS Proxy") -> Optional[str]: prompt: str, default: str, title: str = "TG WS Proxy"
) -> Optional[str]:
prompt_esc = _escape_osascript_text(prompt) prompt_esc = _escape_osascript_text(prompt)
default_esc = _escape_osascript_text(default) default_esc = _escape_osascript_text(default)
title_esc = _escape_osascript_text(title) title_esc = _escape_osascript_text(title)
r = subprocess.run( r = subprocess.run(
['osascript', '-e', [
f'text returned of (display dialog "{prompt_esc}" ' "osascript",
f'default answer "{default_esc}" ' "-e",
f'with title "{title_esc}" ' f'text returned of (display dialog "{prompt_esc}" '
f'buttons {{"Закрыть", "OK"}} ' f'default answer "{default_esc}" '
f'default button "OK" cancel button "Закрыть")'], f'with title "{title_esc}" '
capture_output=True, text=True) f'buttons {{"Закрыть", "OK"}} '
f'default button "OK" cancel button "Закрыть")',
],
capture_output=True,
text=True,
)
if r.returncode != 0: if r.returncode != 0:
return None return None
return r.stdout.rstrip("\r\n") return r.stdout.rstrip("\r\n")
@ -439,59 +295,46 @@ def _toggle_check_updates(_=None):
def _on_open_release_page(_=None): def _on_open_release_page(_=None):
from utils.update_check import RELEASES_PAGE_URL from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL) webbrowser.open(RELEASES_PAGE_URL)
def _maybe_notify_update_async(): def _maybe_notify_update_async() -> None:
def _work(): spawn_notify_update_async(
time.sleep(1.5) get_config=lambda: _config,
if _exiting: exiting=lambda: _exiting,
return ask_open_release=lambda ver, _url: _ask_yes_no(
if not _config.get("check_updates", True): f"Доступна новая версия: {ver}\n\n"
return f"Открыть страницу релиза в браузере?",
try: "TG WS Proxy — обновление",
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check ),
run_check(__version__) log=log,
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_yes_no(
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?",
"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()
# Settings via native macOS dialogs
def _edit_config_dialog(): def _edit_config_dialog():
cfg = load_config() cfg = load_config()
# Host
host = _osascript_input( host = _osascript_input(
"IP-адрес прокси:", "IP-адрес прокси:",
cfg.get("host", DEFAULT_CONFIG["host"])) cfg.get("host", DEFAULT_CONFIG["host"]),
)
if host is None: if host is None:
return return
host = host.strip() host = host.strip()
import socket as _sock import socket as _sock
try: try:
_sock.inet_aton(host) _sock.inet_aton(host)
except OSError: except OSError:
_show_error("Некорректный IP-адрес.") _show_error("Некорректный IP-адрес.")
return return
# Port
port_str = _osascript_input( port_str = _osascript_input(
"Порт прокси:", "Порт прокси:",
str(cfg.get("port", DEFAULT_CONFIG["port"]))) str(cfg.get("port", DEFAULT_CONFIG["port"])),
)
if port_str is None: if port_str is None:
return return
try: try:
@ -502,42 +345,41 @@ def _edit_config_dialog():
_show_error("Порт должен быть числом 1-65535") _show_error("Порт должен быть числом 1-65535")
return return
# DC-IP mappings
dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))
dc_str = _osascript_input( dc_str = _osascript_input(
"DC → IP маппинги (через запятую, формат DC:IP):\n" "DC → IP маппинги (через запятую, формат DC:IP):\n"
"Например: 2:149.154.167.220, 4:149.154.167.220", "Например: 2:149.154.167.220, 4:149.154.167.220",
dc_default) dc_default,
)
if dc_str is None: if dc_str is None:
return return
dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() dc_lines = [
if s.strip()] s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()
]
try: try:
tg_ws_proxy.parse_dc_ip_list(dc_lines) tg_ws_proxy.parse_dc_ip_list(dc_lines)
except ValueError as e: except ValueError as e:
_show_error(str(e)) _show_error(str(e))
return return
# Verbose
verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?") verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?")
if verbose is None: if verbose is None:
return return
# Advanced settings
adv_str = _osascript_input( adv_str = _osascript_input(
"Расширенные настройки (буфер KB, WS пул, лог MB):\n" "Расширенные настройки (буфер KB, WS пул, лог MB):\n"
"Формат: buf_kb,pool_size,log_max_mb", "Формат: buf_kb,pool_size,log_max_mb",
f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])},"
f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])},"
f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}",
)
if adv_str is None: if adv_str is None:
return return
adv = {} adv = {}
if adv_str: if adv_str:
parts = [s.strip() for s in adv_str.split(',')] parts = [s.strip() for s in adv_str.split(",")]
keys = [("buf_kb", int), ("pool_size", int), keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)]
("log_max_mb", float)]
for i, (k, typ) in enumerate(keys): for i, (k, typ) in enumerate(keys):
if i < len(parts): if i < len(parts):
try: try:
@ -551,8 +393,12 @@ def _edit_config_dialog():
"dc_ip": dc_lines, "dc_ip": dc_lines,
"verbose": verbose, "verbose": verbose,
"buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])),
"pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), "pool_size": adv.get(
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), "pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
),
"log_max_mb": adv.get(
"log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])
),
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@ -562,13 +408,10 @@ def _edit_config_dialog():
if _app: if _app:
_app.update_menu_title() _app.update_menu_title()
if _ask_yes_no_close( if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
"Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
restart_proxy() restart_proxy()
# First-run & IPv6 dialogs
def _show_first_run(): def _show_first_run():
_ensure_dirs() _ensure_dirs()
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
@ -596,78 +439,57 @@ def _show_first_run():
_on_open_in_telegram() _on_open_in_telegram()
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning(): def _check_ipv6_warning():
_ensure_dirs() _ensure_dirs()
if IPV6_WARN_MARKER.exists(): if IPV6_WARN_MARKER.exists():
return return
if not _has_ipv6_enabled(): if not has_ipv6_enabled("simple"):
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
_show_info( _show_info(IPV6_WARN_BODY_MACOS)
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
"Это предупреждение будет показано только один раз.")
# rumps menubar app
_TgWsProxyAppBase = rumps.App if rumps else object _TgWsProxyAppBase = rumps.App if rumps else object
class TgWsProxyApp(_TgWsProxyAppBase): class TgWsProxyApp(_TgWsProxyAppBase):
def __init__(self): def __init__(self):
_ensure_menubar_icon() _ensure_menubar_icon()
icon_path = (str(MENUBAR_ICON_PATH) icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
if MENUBAR_ICON_PATH.exists() else None)
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item = rumps.MenuItem( self._open_tg_item = rumps.MenuItem(
f"Открыть в Telegram ({host}:{port})", f"Открыть в Telegram ({host}:{port})",
callback=_on_open_in_telegram) callback=_on_open_in_telegram,
)
self._restart_item = rumps.MenuItem( self._restart_item = rumps.MenuItem(
"Перезапустить прокси", "Перезапустить прокси",
callback=_on_restart) callback=_on_restart,
)
self._settings_item = rumps.MenuItem( self._settings_item = rumps.MenuItem(
"Настройки...", "Настройки...",
callback=_on_edit_config) callback=_on_edit_config,
)
self._logs_item = rumps.MenuItem( self._logs_item = rumps.MenuItem(
"Открыть логи", "Открыть логи",
callback=_on_open_logs) callback=_on_open_logs,
)
self._release_page_item = rumps.MenuItem( self._release_page_item = rumps.MenuItem(
"Страница релиза на GitHub…", "Страница релиза на GitHub…",
callback=_on_open_release_page) callback=_on_open_release_page,
)
self._check_updates_item = rumps.MenuItem( self._check_updates_item = rumps.MenuItem(
_check_updates_menu_title(), _check_updates_menu_title(),
callback=_toggle_check_updates) callback=_toggle_check_updates,
)
self._version_item = rumps.MenuItem( self._version_item = rumps.MenuItem(
f"Версия {__version__}", f"Версия {__version__}",
callback=lambda _: None) callback=lambda _: None,
)
super().__init__( super().__init__(
"TG WS Proxy", "TG WS Proxy",
@ -685,13 +507,13 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._check_updates_item, self._check_updates_item,
None, None,
self._version_item, self._version_item,
]) ],
)
def update_menu_title(self): def update_menu_title(self):
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item.title = ( self._open_tg_item.title = f"Открыть в Telegram ({host}:{port})"
f"Открыть в Telegram ({host}:{port})")
def run_menubar(): def run_menubar():
@ -706,8 +528,10 @@ def run_menubar():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False), setup_logging(
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) _config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("TG WS Proxy версия %s, menubar app starting", __version__)
log.info("Config: %s", _config) log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
@ -738,14 +562,14 @@ def run_menubar():
def main(): def main():
if not _acquire_lock(): if not _instance_lock.acquire():
_show_info("Приложение уже запущено.") _show_info("Приложение уже запущено.")
return return
try: try:
run_menubar() run_menubar()
finally: finally:
_release_lock() _instance_lock.release()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,4 +1,4 @@
""" """
Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки. Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки, иконки.
Ядро прокси пакет `proxy`. Ядро прокси пакет `proxy`; общая логика экземпляра и конфига `utils.tray_*`.
""" """

12
ui/tray_ctk.py Normal file
View File

@ -0,0 +1,12 @@
"""Общие операции с окнами CustomTkinter / tkinter."""
from __future__ import annotations
def destroy_root_safely(root) -> None:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass

46
ui/tray_icons.py Normal file
View File

@ -0,0 +1,46 @@
"""Иконка tray: загрузка icon.ico или синтез буквы «T» (Pillow)."""
from __future__ import annotations
from pathlib import Path
from typing import Any, List
from PIL import Image, ImageDraw, ImageFont
def _pick_font(size: int, candidates: List[str]) -> Any:
for path in candidates:
try:
return ImageFont.truetype(path, size=int(size * 0.55))
except Exception:
continue
return ImageFont.load_default()
def synthesize_letter_t_icon(size: int, font_candidates: List[str]) -> Image.Image:
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=(0, 136, 204, 255),
)
font = _pick_font(size, font_candidates)
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def load_ico_or_synthesize(
ico_path: Path,
font_candidates: List[str],
size: int = 64,
) -> Image.Image:
if ico_path.exists():
try:
return Image.open(str(ico_path))
except Exception:
pass
return synthesize_letter_t_icon(size, font_candidates)

76
utils/tray_io.py Normal file
View File

@ -0,0 +1,76 @@
"""Конфиг tray-приложения и логирование в файл."""
from __future__ import annotations
import json
import logging
import logging.handlers
import sys
from pathlib import Path
from typing import Any, Dict, Mapping
from utils.tray_paths import TrayPaths
def ensure_app_dirs(paths: TrayPaths) -> None:
paths.app_dir.mkdir(parents=True, exist_ok=True)
def load_tray_config(
paths: TrayPaths,
defaults: Mapping[str, Any],
log: logging.Logger,
) -> Dict[str, Any]:
ensure_app_dirs(paths)
if paths.config_file.exists():
try:
with open(paths.config_file, "r", encoding="utf-8") as f:
data: Dict[str, Any] = json.load(f)
for k, v in defaults.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(defaults)
def save_tray_config(paths: TrayPaths, cfg: Mapping[str, Any]) -> None:
ensure_app_dirs(paths)
with open(paths.config_file, "w", encoding="utf-8") as f:
json.dump(dict(cfg), f, indent=2, ensure_ascii=False)
def setup_tray_logging(
paths: TrayPaths,
*,
verbose: bool = False,
log_max_mb: float = 5,
) -> None:
ensure_app_dirs(paths)
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(paths.log_file),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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)

79
utils/tray_ipv6.py Normal file
View File

@ -0,0 +1,79 @@
"""Эвристика наличия IPv6 и тексты предупреждения для tray."""
from __future__ import annotations
import ipaddress
import socket as _sock
from typing import Literal
Ipv6DetectMode = Literal["full", "simple"]
def has_ipv6_enabled(mode: Ipv6DetectMode = "simple") -> bool:
if mode == "full":
return _has_ipv6_full()
return _has_ipv6_simple()
def _has_ipv6_simple() -> bool:
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _has_ipv6_full() -> bool:
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if not ip or ip.startswith("::1"):
continue
try:
if ipaddress.IPv6Address(ip).is_link_local:
continue
except ValueError:
if ip.startswith("fe80:"):
continue
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
IPV6_WARN_BODY_LONG = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз."
)
IPV6_WARN_BODY_MACOS = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
"Это предупреждение будет показано только один раз."
)

132
utils/tray_lock.py Normal file
View File

@ -0,0 +1,132 @@
"""Один экземпляр tray-приложения: lock-файлы с PID и метаданными."""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Callable, Optional
import psutil
import sys
SameProcessFn = Callable[[dict, psutil.Process], bool]
def make_same_process_checker(
*,
script_marker: Optional[str],
frozen_match: Callable[[psutil.Process], bool],
) -> SameProcessFn:
"""Проверка «наш ли процесс» для lock-файла (cmdline + frozen)."""
def check(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
if script_marker is not None:
try:
for arg in proc.cmdline():
if script_marker in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return frozen_match(proc)
return False
return check
def frozen_match_executable_basename(proc: psutil.Process) -> bool:
import os as _os
return _os.path.basename(sys.executable).lower() == proc.name().lower()
def frozen_match_app_name_contains(app_name: str) -> Callable[[psutil.Process], bool]:
needle = app_name.lower()
def _m(proc: psutil.Process) -> bool:
return needle in proc.name().lower()
return _m
class SingleInstanceLock:
"""RAII-совместимый lock каталога приложения (*.lock с PID)."""
def __init__(
self,
app_dir: Path,
same_process: SameProcessFn,
*,
log: Optional[logging.Logger] = None,
) -> None:
self._app_dir = app_dir
self._same_process = same_process
self._log = log
self._lock_file: Optional[Path] = None
@property
def lock_file(self) -> Optional[Path]:
return self._lock_file
def acquire(self) -> bool:
self._app_dir.mkdir(parents=True, exist_ok=True)
for f in self._app_dir.glob("*.lock"):
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if self._same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = self._app_dir / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {"create_time": proc.create_time()}
lock_file.write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
except Exception:
lock_file.touch()
self._lock_file = lock_file
return True
def release(self) -> None:
if not self._lock_file:
return
try:
self._lock_file.unlink(missing_ok=True)
except Exception:
if self._log:
self._log.debug("Lock release failed", exc_info=True)
self._lock_file = None

44
utils/tray_paths.py Normal file
View File

@ -0,0 +1,44 @@
"""Пути данных tray-приложения (конфиг, логи, маркеры) по ОС."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
APP_NAME = "TgWsProxy"
@dataclass(frozen=True)
class TrayPaths:
app_dir: Path
config_file: Path
log_file: Path
first_run_marker: Path
ipv6_warn_marker: Path
def tray_paths_windows() -> TrayPaths:
base = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
return _paths_under(base)
def tray_paths_linux() -> TrayPaths:
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) / APP_NAME if xdg else Path.home() / ".config" / APP_NAME
return _paths_under(base)
def tray_paths_macos() -> TrayPaths:
base = Path.home() / "Library" / "Application Support" / APP_NAME
return _paths_under(base)
def _paths_under(app_dir: Path) -> TrayPaths:
return TrayPaths(
app_dir=app_dir,
config_file=app_dir / "config.json",
log_file=app_dir / "proxy.log",
first_run_marker=app_dir / ".first_run_done",
ipv6_warn_marker=app_dir / ".ipv6_warned",
)

128
utils/tray_proxy_runner.py Normal file
View File

@ -0,0 +1,128 @@
"""Запуск asyncio-прокси в отдельном потоке (общий для tray entrypoints)."""
from __future__ import annotations
import asyncio as _asyncio
import threading
import time
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
import proxy.tg_ws_proxy as tg_ws_proxy
ProxyStopState = Tuple[Any, Any] # (loop, Event)
class ProxyThreadRunner:
"""Управляет потоком с tg_ws_proxy._run и корректной остановкой."""
def __init__(
self,
*,
default_config: Mapping[str, Any],
get_config: Callable[[], Dict[str, Any]],
log: Any,
show_error: Callable[[str], None],
join_timeout: float = 2.0,
warn_on_join_stuck: bool = False,
treat_win_error_10048_as_port_in_use: bool = False,
) -> None:
self._default = dict(default_config)
self._get_config = get_config
self._log = log
self._show_error = show_error
self._join_timeout = join_timeout
self._warn_on_join_stuck = warn_on_join_stuck
self._win10048 = treat_win_error_10048_as_port_in_use
self._thread: Optional[threading.Thread] = None
self._async_stop: Optional[ProxyStopState] = None
@property
def async_stop(self) -> Optional[ProxyStopState]:
return self._async_stop
def _run_proxy_thread(
self,
port: int,
dc_opt: Dict[int, str],
verbose: bool,
host: str = "127.0.0.1",
) -> None:
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(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)
)
except Exception as exc:
self._log.error("Proxy thread crashed: %s", exc)
msg = str(exc)
port_busy = "Address already in use" in msg
if self._win10048 and "10048" in msg:
port_busy = True
if port_busy:
self._show_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
self._async_stop = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
self._log.info("Proxy already running")
return
cfg = self._get_config()
port = cfg.get("port", self._default["port"])
host = cfg.get("host", self._default["host"])
dc_ip_list = cfg.get("dc_ip", self._default["dc_ip"])
verbose = bool(cfg.get("verbose", False))
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
self._log.error("Bad config dc_ip: %s", e)
self._show_error(f"Ошибка конфигурации:\n{e}")
return
self._log.info("Starting proxy on %s:%d ...", host, port)
buf_kb = cfg.get("buf_kb", self._default["buf_kb"])
pool_size = cfg.get("pool_size", self._default["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
self._thread = threading.Thread(
target=self._run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
self._thread.start()
def stop(self) -> None:
if self._async_stop:
loop, stop_ev = self._async_stop
loop.call_soon_threadsafe(stop_ev.set)
if self._thread:
self._thread.join(timeout=self._join_timeout)
if self._warn_on_join_stuck and self._thread.is_alive():
self._log.warning(
"Proxy thread did not finish within timeout; "
"the process may still exit shortly"
)
self._thread = None
self._log.info("Proxy stopped")
def restart(self) -> None:
self._log.info("Restarting proxy...")
self.stop()
time.sleep(0.3)
self.start()

43
utils/tray_updates.py Normal file
View File

@ -0,0 +1,43 @@
"""Фоновая проверка обновлений GitHub Releases для tray."""
from __future__ import annotations
import logging
import threading
import time
import webbrowser
from typing import Callable, Mapping
from proxy import __version__
def spawn_notify_update_async(
*,
get_config: Callable[[], Mapping[str, object]],
exiting: Callable[[], bool],
ask_open_release: Callable[[str, str], bool],
log: logging.Logger,
) -> None:
"""Пауза, затем run_check; при наличии обновления — ask и открытие браузера."""
def _work() -> None:
time.sleep(1.5)
if exiting():
return
cfg = get_config()
if not cfg.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
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_release(str(ver), url):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()

View File

@ -1,20 +1,15 @@
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import ipaddress
import json
import logging import logging
import logging.handlers
import os import os
import winreg
import psutil
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import asyncio as _asyncio import winreg
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Optional
try: try:
import pyperclip import pyperclip
@ -32,13 +27,11 @@ except ImportError:
ctk = None ctk = None
try: try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image
except ImportError: except ImportError:
Image = ImageDraw = ImageFont = None Image = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_buttons,
install_tray_config_form, install_tray_config_form,
@ -54,27 +47,34 @@ from ui.ctk_theme import (
ctk_theme_for_platform, ctk_theme_for_platform,
main_content_frame, main_content_frame,
) )
from ui.tray_ctk import destroy_root_safely
from ui.tray_icons import load_ico_or_synthesize
from utils.default_config import default_tray_config
from utils.tray_io import load_tray_config, save_tray_config, setup_tray_logging
from utils.tray_ipv6 import IPV6_WARN_BODY_LONG, has_ipv6_enabled
from utils.tray_lock import (
SingleInstanceLock,
frozen_match_executable_basename,
make_same_process_checker,
)
from utils.tray_paths import APP_NAME, tray_paths_windows
from utils.tray_proxy_runner import ProxyThreadRunner
from utils.tray_updates import spawn_notify_update_async
IS_FROZEN = bool(getattr(sys, "frozen", False)) IS_FROZEN = bool(getattr(sys, "frozen", False))
APP_NAME = "TgWsProxy" PATHS = tray_paths_windows()
APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME APP_DIR = PATHS.app_dir
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = PATHS.config_file
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = PATHS.log_file
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" FIRST_RUN_MARKER = PATHS.first_run_marker
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" IPV6_WARN_MARKER = PATHS.ipv6_warn_marker
DEFAULT_CONFIG = default_tray_config() DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None _tray_icon: Optional[object] = None
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
@ -87,137 +87,57 @@ _user32.MessageBoxW.argtypes = [
] ]
_user32.MessageBoxW.restype = ctypes.c_int _user32.MessageBoxW.restype = ctypes.c_int
_instance_lock = SingleInstanceLock(
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: PATHS.app_dir,
try: make_same_process_checker(
lock_ct = float(lock_meta.get("create_time", 0.0)) script_marker="windows.py",
proc_ct = float(proc.create_time()) frozen_match=frozen_match_executable_basename,
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: ),
return False log=log,
except Exception: )
return False
try:
for arg in proc.cmdline():
if "windows.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return (
os.path.basename(sys.executable).lower() == proc.name().lower()
)
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict: def load_config() -> dict:
_ensure_dirs() return load_tray_config(PATHS, DEFAULT_CONFIG, log)
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) -> None:
_ensure_dirs() save_tray_config(PATHS, 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, log_max_mb: float = 5): def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
_ensure_dirs() setup_tray_logging(PATHS, verbose=verbose, log_max_mb=log_max_mb)
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
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): def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
ch = logging.StreamHandler(sys.stdout) _user32.MessageBoxW(None, text, title, 0x10)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s", def _show_info(text: str, title: str = "TG WS Proxy") -> None:
datefmt="%H:%M:%S")) _user32.MessageBoxW(None, text, title, 0x40)
root.addHandler(ch)
_proxy_runner = ProxyThreadRunner(
default_config=DEFAULT_CONFIG,
get_config=lambda: _config,
log=log,
show_error=_show_error,
join_timeout=5.0,
warn_on_join_stuck=True,
treat_win_error_10048_as_port_in_use=True,
)
def start_proxy() -> None:
_proxy_runner.start()
def stop_proxy() -> None:
_proxy_runner.stop()
def restart_proxy() -> None:
_proxy_runner.restart()
def _autostart_reg_name() -> str: def _autostart_reg_name() -> str:
@ -278,126 +198,17 @@ def set_autostart_enabled(enabled: bool) -> None:
) )
def _make_icon_image(size: int = 64): def _load_icon():
if Image is None: if Image is None:
raise RuntimeError("Pillow is required for tray icon") raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) assets = Path(__file__).parent
draw = ImageDraw.Draw(img) return load_ico_or_synthesize(
assets / "icon.ico",
margin = 2 ["arial.ttf", str(Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / "arial.ttf")],
draw.ellipse([margin, margin, size - margin, size - margin], )
fill=(0, 136, 204, 255))
try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon(): def _ask_open_release_page(latest_version: str, _url: str) -> bool:
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
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():
global _proxy_thread, _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)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy():
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)
if _proxy_thread.is_alive():
log.warning(
"Proxy thread did not finish within timeout; "
"the process may still exit shortly")
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
_user32.MessageBoxW(None, text, title, 0x10)
def _show_info(text: str, title: str = "TG WS Proxy"):
_user32.MessageBoxW(None, text, title, 0x40)
def _ask_open_release_page(latest_version: str, url: str) -> bool:
"""Win32 Yes/No: открыть страницу релиза."""
MB_YESNO = 0x4 MB_YESNO = 0x4
MB_ICONQUESTION = 0x20 MB_ICONQUESTION = 0x20
IDYES = 6 IDYES = 6
@ -414,30 +225,16 @@ def _ask_open_release_page(latest_version: str, url: str) -> bool:
return r == IDYES return r == IDYES
def _maybe_notify_update_async(): def _maybe_notify_update_async() -> None:
""" def ask(ver: str, url: str) -> bool:
Фоновая проверка GitHub Releases и уведомление (не блокирует трей). return _ask_open_release_page(ver, url)
"""
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
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_release_page(str(ver), url):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start() spawn_notify_update_async(
get_config=lambda: _config,
exiting=lambda: _exiting,
ask_open_release=ask,
log=log,
)
def _on_open_in_telegram(icon=None, item=None): def _on_open_in_telegram(icon=None, item=None):
@ -454,14 +251,16 @@ def _on_open_in_telegram(icon=None, item=None):
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(
"Не удалось открыть Telegram автоматически.\n\n" "Не удалось открыть Telegram автоматически.\n\n"
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
)
return return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
f"Не удалось открыть Telegram автоматически.\n\n" f"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy") "TG WS Proxy",
)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
@ -483,8 +282,6 @@ def _edit_config_dialog():
cfg = dict(_config) cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled() cfg["autostart"] = is_autostart_enabled()
# Make sure that the autostart key is removed if autostart
# is disabled, even if the executable file is moved.
if _supports_autostart() and not cfg["autostart"]: if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False) set_autostart_enabled(False)
@ -539,13 +336,13 @@ def _edit_config_dialog():
_tray_icon.menu = _build_menu() _tray_icon.menu = _build_menu()
# Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
# и даёт зависание; tkinter.messagebox согласован с циклом окна.
from tkinter import messagebox from tkinter import messagebox
if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n" if messagebox.askyesno(
"Перезапустить прокси сейчас?", "Перезапустить?",
parent=root): "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy() root.destroy()
restart_proxy() restart_proxy()
else: else:
@ -555,17 +352,13 @@ def _edit_config_dialog():
root.destroy() root.destroy()
install_tray_config_buttons( install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel) ctk, footer, theme, on_save=on_save, on_cancel=on_cancel
)
try: try:
root.mainloop() root.mainloop()
finally: finally:
import tkinter as tk destroy_root_safely(root)
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None): def _on_open_logs(icon=None, item=None):
@ -587,15 +380,15 @@ def _on_exit(icon=None, item=None):
def _force_exit(): def _force_exit():
time.sleep(3) time.sleep(3)
os._exit(0) os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon: if icon:
icon.stop() icon.stop()
def _show_first_run(): def _show_first_run():
_ensure_dirs() PATHS.app_dir.mkdir(parents=True, exist_ok=True)
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
@ -625,50 +418,20 @@ def _show_first_run():
_on_open_in_telegram() _on_open_in_telegram()
populate_first_run_window( populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done) ctk, root, theme, host=host, port=port, on_done=on_done
)
try: try:
root.mainloop() root.mainloop()
finally: finally:
import tkinter as tk destroy_root_safely(root)
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if not ip or ip.startswith("::1"):
continue
try:
if ipaddress.IPv6Address(ip).is_link_local:
continue
except ValueError:
if ip.startswith("fe80:"):
continue
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning(): def _check_ipv6_warning():
_ensure_dirs() PATHS.app_dir.mkdir(parents=True, exist_ok=True)
if IPV6_WARN_MARKER.exists(): if IPV6_WARN_MARKER.exists():
return return
if not _has_ipv6_enabled(): if not has_ipv6_enabled("full"):
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
@ -677,17 +440,7 @@ def _check_ipv6_warning():
def _show_ipv6_dialog(): def _show_ipv6_dialog():
_show_info( _show_info(IPV6_WARN_BODY_LONG, "TG WS Proxy")
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy")
def _build_menu(): def _build_menu():
@ -699,7 +452,8 @@ def _build_menu():
pystray.MenuItem( pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})", f"Открыть в Telegram ({host}:{port})",
_on_open_in_telegram, _on_open_in_telegram,
default=True), default=True,
),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem("Настройки...", _on_edit_config),
@ -721,8 +475,10 @@ def run_tray():
except Exception: except Exception:
pass pass
setup_logging(_config.get("verbose", False), setup_logging(
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) _config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s, tray app starting", __version__) log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config) log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
@ -730,7 +486,8 @@ def run_tray():
if pystray is None or Image is None or ctk is None: if pystray is None or Image is None or ctk is None:
log.error( log.error(
"pystray, Pillow or customtkinter not installed; " "pystray, Pillow or customtkinter not installed; "
"running in console mode") "running in console mode"
)
start_proxy() start_proxy()
try: try:
while True: while True:
@ -751,7 +508,8 @@ def run_tray():
APP_NAME, APP_NAME,
icon_image, icon_image,
"TG WS Proxy", "TG WS Proxy",
menu=_build_menu()) menu=_build_menu(),
)
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@ -761,14 +519,14 @@ def run_tray():
def main(): def main():
if not _acquire_lock(): if not _instance_lock.acquire():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
finally: finally:
_release_lock() _instance_lock.release()
if __name__ == "__main__": if __name__ == "__main__":