Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/dot_error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/dot_idle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/dot_ok.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
127 changes: 126 additions & 1 deletion utils/tray_common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import enum
import json
import logging
import logging.handlers
Expand Down Expand Up @@ -225,31 +226,155 @@ def load_icon():
return make_icon_image(64)


_STATUS_COLORS = {
"ok": (76, 175, 80, 255), # green — proxy running + active connections
"idle": (255, 193, 7, 255), # amber — proxy running, no connections
"error": (229, 57, 53, 255), # red — proxy stopped/crashed
}


_RENDER_SIZE = 256 # render at high resolution so Windows HiDPI downscaling stays sharp


def _load_dot_image(assets: Path, status: str, size: int):
from PIL import Image

path = assets / f"dot_{status}.png"
if not path.exists():
return None
try:
return Image.open(str(path)).convert("RGBA").resize((size, size), Image.LANCZOS)
except Exception as e:
log.debug("Failed to load dot asset %s: %s", path, e)
return None


def add_status_dot(base_img, status: str):
from PIL import Image, ImageDraw

size = _RENDER_SIZE
img = base_img.convert("RGBA").resize((size, size), Image.LANCZOS)

dot_r = size // 6 # ~42px — proportional badge size
margin = size // 32 # ~8px
border = size // 48 # ~5px — white outline thickness

assets = Path(__file__).parents[1] / "assets"
dot = _load_dot_image(assets, status, dot_r * 2)
if dot is not None:
img.paste(dot, (size - dot_r * 2 - margin, size - dot_r * 2 - margin), dot)
return img

color = _STATUS_COLORS.get(status, _STATUS_COLORS["idle"])
x0 = size - dot_r * 2 - margin
y0 = size - dot_r * 2 - margin
x1 = size - margin
y1 = size - margin

draw = ImageDraw.Draw(img)
draw.ellipse(
[x0 - border, y0 - border, x1 + border, y1 + border],
fill=(255, 255, 255, 255),
)
draw.ellipse([x0, y0, x1, y1], fill=color)
return img


def is_proxy_running() -> bool:
return _async_stop is not None


class ProxyStatus(enum.Enum):
STOPPED = "error"
IDLE = "idle"
ACTIVE = "ok"


class StatusManager:
# status change is confirmed only after it holds for DEBOUNCE_SECS — prevents icon flickering on brief connection drops

DEBOUNCE_SECS = 2.0
POLL_SECS = 1.0

def __init__(
self,
on_change: Callable[[ProxyStatus, Optional["ProxyStatus"]], None],
on_tick: Optional[Callable[[], None]] = None,
) -> None:
self._on_change = on_change
self._on_tick = on_tick
self._current: Optional[ProxyStatus] = None
self._pending: Optional[ProxyStatus] = None
self._pending_since: float = 0.0

def _raw_status(self) -> ProxyStatus:
from proxy.stats import stats
if not is_proxy_running():
return ProxyStatus.STOPPED
if stats.connections_active > 0:
return ProxyStatus.ACTIVE
return ProxyStatus.IDLE

def tick(self) -> None:
raw = self._raw_status()
now = time.monotonic()

if raw == self._current:
self._pending = None
return

if raw != self._pending:
self._pending = raw
self._pending_since = now
return

if now - self._pending_since >= self.DEBOUNCE_SECS:
previous = self._current
self._current = self._pending
self._pending = None
self._on_change(self._current, previous)

def start(self, stop_flag: Callable[[], bool]) -> None:
def _work() -> None:
while not stop_flag():
self.tick()
if self._on_tick:
self._on_tick()
time.sleep(self.POLL_SECS)

threading.Thread(target=_work, daemon=True, name="icon-updater").start()


# proxy lifecycle

_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
_crash_reason: Optional[str] = None


def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
global _async_stop
global _async_stop, _crash_reason

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
stop_ev = asyncio.Event()
_async_stop = (loop, stop_ev)
_crash_reason = None

try:
loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc):
_crash_reason = "port_busy"
on_port_busy(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
else:
_crash_reason = repr(exc)
finally:
loop.close()
_async_stop = None
Expand Down
158 changes: 143 additions & 15 deletions windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
)
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
acquire_lock, add_status_dot, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, is_proxy_running, load_config, load_icon, log,
ProxyStatus, StatusManager,
quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
import utils.tray_common as _tray_common
from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
Expand Down Expand Up @@ -96,7 +98,52 @@ def _release_win_mutex() -> None:

ICON_PATH = str(Path(__file__).parent / "icon.ico")

# win32 dialogs

class _GUID(ctypes.Structure):
_fields_ = [("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort),
("Data3", ctypes.c_ushort), ("Data4", ctypes.c_byte * 8)]

class _NOTIFYICONDATA(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
("hWnd", ctypes.c_void_p),
("uID", ctypes.c_uint),
("uFlags", ctypes.c_uint),
("uCallbackMessage",ctypes.c_uint),
("hIcon", ctypes.c_void_p),
("szTip", ctypes.c_wchar * 128),
("dwState", ctypes.c_uint),
("dwStateMask", ctypes.c_uint),
("szInfo", ctypes.c_wchar * 256),
("uVersion", ctypes.c_uint),
("szInfoTitle", ctypes.c_wchar * 64),
("dwInfoFlags", ctypes.c_uint),
("guidItem", _GUID),
("hBalloonIcon", ctypes.c_void_p),
]

_NIM_MODIFY = 0x00000001
_NIF_INFO = 0x00000010
_NIIF_NOSOUND = 0x00000010

_shell32 = ctypes.windll.shell32


def _balloon_notify(title: str, message: str, icon=None) -> None:
target = icon if icon is not None else _tray_icon
hwnd = getattr(target, "_hwnd", None)
if not hwnd:
return
nid = _NOTIFYICONDATA()
nid.cbSize = ctypes.sizeof(_NOTIFYICONDATA)
nid.hWnd = hwnd
nid.uID = 0 # pystray registers with uID=0 (passes hID=id(self) kwarg which ctypes silently ignores)
nid.uFlags = _NIF_INFO
nid.dwInfoFlags = _NIIF_NOSOUND
nid.szInfo = message[:255]
nid.szInfoTitle = title[:63]
_shell32.Shell_NotifyIconW(_NIM_MODIFY, ctypes.byref(nid))


_u32 = ctypes.windll.user32
_u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint]
Expand Down Expand Up @@ -292,7 +339,7 @@ def _err(msg: str) -> None:
_release_win_mutex()
stop_proxy()

# Don't reuse existing _MEI* dir
# prevent the new process from inheriting the old PyInstaller _MEI* temp dir
env = os.environ.copy()
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
del env[_k]
Expand Down Expand Up @@ -347,8 +394,6 @@ def _work():
threading.Thread(target=_work, daemon=True, name="update-check").start()


# autostart (registry)

_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"


Expand Down Expand Up @@ -388,8 +433,6 @@ def set_autostart_enabled(enabled: bool) -> None:
)


# tray callbacks

def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Opening %s", url)
Expand Down Expand Up @@ -465,8 +508,6 @@ def _on_exit(icon=None, item=None) -> None:
icon.stop()


# settings dialog

def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.")
Expand Down Expand Up @@ -547,8 +588,6 @@ def on_save() -> None:
ctk_run_dialog(_build)


# first run

def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
Expand Down Expand Up @@ -581,7 +620,96 @@ def on_done(open_tg: bool) -> None:
ctk_run_dialog(_build)


# tray menu
_dc_pings: dict[int, int | None] = {}


def _ping_tcp(ip: str, port: int = 443, timeout: float = 3.0) -> int | None:
import socket
try:
t0 = time.monotonic()
with socket.create_connection((ip, port), timeout=timeout):
pass
return int((time.monotonic() - t0) * 1000)
except Exception:
return None


def _start_dc_pinger() -> None:
def _work() -> None:
while not _exiting:
from proxy.config import proxy_config
for dc, ip in list(proxy_config.dc_redirects.items()):
_dc_pings[dc] = _ping_tcp(ip)
time.sleep(30)

threading.Thread(target=_work, daemon=True, name="dc-pinger").start()


def _start_icon_updater() -> None:
from proxy.stats import stats
from proxy.utils import human_bytes

_base_icon = load_icon()
_prev_bytes: list[int] = [0, 0] # [bytes_up, bytes_down] from previous tick

def _on_status_change(status: ProxyStatus, previous: ProxyStatus) -> None:
if _tray_icon is None:
return
try:
_tray_icon.icon = add_status_dot(_base_icon, status.value)
except Exception:
pass
if status == ProxyStatus.STOPPED and previous is not None:
reason = _tray_common._crash_reason
if reason == "port_busy":
msg = "Прокси остановлен: порт занят другим приложением"
elif reason:
msg = "Прокси упал — проверьте логи"
else:
msg = "Прокси остановлен"
try:
_balloon_notify("TG WS Proxy", msg, icon=_tray_icon)
except Exception:
pass
_prev_bytes[0] = 0
_prev_bytes[1] = 0

def _on_tick() -> None:
if _tray_icon is None:
return

ping_str = " ".join(
f"DC{dc}: {ms}ms" if ms is not None else f"DC{dc}: —"
for dc, ms in sorted(_dc_pings.items())
)

speed_up = max(0, stats.bytes_up - _prev_bytes[0])
speed_down = max(0, stats.bytes_down - _prev_bytes[1])
_prev_bytes[0] = stats.bytes_up
_prev_bytes[1] = stats.bytes_down

if not is_proxy_running():
title = "TG WS Proxy — не запущен"
elif stats.connections_active > 0:
title = (
f"TG WS Proxy\n"
f"Активных: {stats.connections_active}\n"
f"↑ {human_bytes(speed_up)}/s ↓ {human_bytes(speed_down)}/s"
)
if ping_str:
title += f"\n{ping_str}"
else:
title = "TG WS Proxy"
if ping_str:
title += f"\n{ping_str}"

try:
_tray_icon.title = title
except Exception:
pass

StatusManager(_on_status_change, on_tick=_on_tick).start(lambda: _exiting)


def _build_menu():
if pystray is None:
Expand All @@ -601,8 +729,6 @@ def _build_menu():
)


# entry point

def run_tray() -> None:
global _tray_icon, _config

Expand All @@ -629,6 +755,8 @@ def run_tray() -> None:
check_ipv6_warning(_show_info)

_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
_start_icon_updater()
_start_dc_pinger()
log.info("Tray icon running")
_tray_icon.run()

Expand Down