From fc822cb70d3fff46d0947fec72482dd3bab70d36 Mon Sep 17 00:00:00 2001 From: deexsed Date: Tue, 24 Mar 2026 16:20:56 +0300 Subject: [PATCH] refactor: extract UI to ui package, tooltips, tg:// host fix - Add ui/ (ctk_theme, ctk_tray_ui, ctk_tooltip): shared Windows/Linux theme, settings form, first-run, validation - Register ui package in pyproject.toml wheel - Tooltips on settings fields for new users - tg://socks URL uses configured host (not hardcoded 127.0.0.1) on Windows, Linux, macOS --- linux.py | 359 ++++++++-------------------------------------- macos.py | 3 +- pyproject.toml | 2 +- ui/__init__.py | 4 + ui/ctk_theme.py | 88 ++++++++++++ ui/ctk_tooltip.py | 114 +++++++++++++++ ui/ctk_tray_ui.py | 353 +++++++++++++++++++++++++++++++++++++++++++++ windows.py | 306 +++++++++------------------------------ 8 files changed, 687 insertions(+), 542 deletions(-) create mode 100644 ui/__init__.py create mode 100644 ui/ctk_theme.py create mode 100644 ui/ctk_tooltip.py create mode 100644 ui/ctk_tray_ui.py diff --git a/linux.py b/linux.py index 664c948..a74214a 100644 --- a/linux.py +++ b/linux.py @@ -19,6 +19,20 @@ import pystray from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from ui.ctk_tray_ui import ( + install_tray_config_buttons, + install_tray_config_form, + populate_first_run_window, + validate_config_form, +) +from ui.ctk_theme import ( + CONFIG_DIALOG_FRAME_PAD, + CONFIG_DIALOG_SIZE, + FIRST_RUN_SIZE, + create_ctk_root, + ctk_theme_for_platform, + main_content_frame, +) APP_NAME = "TgWsProxy" APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME @@ -226,6 +240,16 @@ def _load_icon(): 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" ): @@ -325,8 +349,9 @@ def _show_info(text: str, title: str = "TG WS Proxy"): def _on_open_in_telegram(icon=None, item=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" + url = f"tg://socks?server={host}&port={port}" log.info("Copying %s", url) try: @@ -355,192 +380,34 @@ def _edit_config_dialog(): cfg = dict(_config) - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") + theme = ctk_theme_for_platform() + w, h = CONFIG_DIALOG_SIZE - root = ctk.CTk() - root.title("TG WS Proxy — Настройки") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - w, h = 420, 540 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=24, pady=20) - - # Host - ctk.CTkLabel( - frame, - text="IP-адрес прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1")) - host_entry = ctk.CTkEntry( - frame, - textvariable=host_var, - width=200, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, + root = create_ctk_root( + ctk, + title="TG WS Proxy — Настройки", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, ) - host_entry.pack(anchor="w", pady=(0, 12)) - # Port - ctk.CTkLabel( - frame, - text="Порт прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - port_var = ctk.StringVar(value=str(cfg.get("port", 1080))) - port_entry = ctk.CTkEntry( - frame, - textvariable=port_var, - width=120, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, + fpx, fpy = CONFIG_DIALOG_FRAME_PAD + frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) + + widgets = install_tray_config_form( + ctk, frame, theme, cfg, DEFAULT_CONFIG, + show_autostart=False, ) - port_entry.pack(anchor="w", pady=(0, 12)) - - # DC-IP mappings - ctk.CTkLabel( - frame, - text="DC → IP маппинги (по одному на строку, формат DC:IP)", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - dc_textbox = ctk.CTkTextbox( - frame, - width=370, - height=120, - font=("Monospace", 12), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, - ) - dc_textbox.pack(anchor="w", pady=(0, 12)) - dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))) - - # Verbose - verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - ctk.CTkCheckBox( - frame, - text="Подробное логирование (verbose)", - variable=verbose_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 8)) - - # Advanced: buf_kb, pool_size, log_max_mb - adv_frame = ctk.CTkFrame(frame, fg_color="transparent") - adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) - - for col, (lbl, key, w_) in enumerate([ - ("Буфер (KB, 256 default)", "buf_kb", 120), - ("WS пулов (4 default)", "pool_size", 120), - ("Log size (MB, 5 def)", "log_max_mb", 120), - ]): - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(side="left", padx=(0, 10)) - ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), - text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") - ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), - corner_radius=8, fg_color=FIELD_BG, - border_color=FIELD_BORDER, border_width=1, - text_color=TEXT_PRIMARY, - textvariable=ctk.StringVar( - value=str(cfg.get(key, DEFAULT_CONFIG[key])) - )).pack(anchor="w") - - _adv_entries = list(adv_frame.winfo_children()) - _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] def on_save(): - import socket as _sock - - host_val = host_var.get().strip() - try: - _sock.inet_aton(host_val) - except OSError: - _show_error("Некорректный IP-адрес.") + merged = validate_config_form( + widgets, DEFAULT_CONFIG, include_autostart=False) + if isinstance(merged, str): + _show_error(merged) return - try: - port_val = int(port_var.get().strip()) - if not (1 <= port_val <= 65535): - raise ValueError - except ValueError: - _show_error("Порт должен быть числом 1-65535") - return - - lines = [ - l.strip() - for l in dc_textbox.get("1.0", "end").strip().splitlines() - if l.strip() - ] - try: - tg_ws_proxy.parse_dc_ip_list(lines) - except ValueError as e: - _show_error(str(e)) - return - - new_cfg = { - "host": host_val, - "port": port_val, - "dc_ip": lines, - "verbose": verbose_var.get(), - } - - for i, key in enumerate(_adv_keys): - col_frame = _adv_entries[i] - entry = col_frame.winfo_children()[1] - try: - val = float(entry.get().strip()) - if key in ("buf_kb", "pool_size"): - val = int(val) - new_cfg[key] = val - except ValueError: - new_cfg[key] = DEFAULT_CONFIG[key] + new_cfg = merged save_config(new_cfg) _config.update(new_cfg) log.info("Config saved: %s", new_cfg) @@ -562,19 +429,8 @@ def _edit_config_dialog(): def on_cancel(): root.destroy() - btn_frame = ctk.CTkFrame(frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=(20, 0)) - ctk.CTkButton(btn_frame, text="Сохранить", height=38, - font=(FONT_FAMILY, 14, "bold"), corner_radius=10, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8)) - ctk.CTkButton(btn_frame, text="Отмена", height=38, - font=(FONT_FAMILY, 14), corner_radius=10, - fg_color=FIELD_BG, hover_color=FIELD_BORDER, - text_color=TEXT_PRIMARY, border_width=1, - border_color=FIELD_BORDER, - command=on_cancel).pack(side="right", fill="x", expand=True) + install_tray_config_buttons( + ctk, frame, theme, on_save=on_save, on_cancel=on_cancel) root.mainloop() @@ -624,127 +480,32 @@ def _show_first_run(): host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" if ctk is None: FIRST_RUN_MARKER.touch() return - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") + theme = ctk_theme_for_platform() + w, h = FIRST_RUN_SIZE - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - root = ctk.CTk() - root.title("TG WS Proxy") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - w, h = 520, 440 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=28, pady=24) - - title_frame = ctk.CTkFrame(frame, fg_color="transparent") - title_frame.pack(anchor="w", pady=(0, 16), fill="x") - - # Blue accent bar - accent_bar = ctk.CTkFrame( - title_frame, fg_color=TG_BLUE, width=4, height=32, corner_radius=2 - ) - accent_bar.pack(side="left", padx=(0, 12)) - - ctk.CTkLabel( - title_frame, - text="Прокси запущен и работает в системном трее", - font=(FONT_FAMILY, 17, "bold"), - text_color=TEXT_PRIMARY, - ).pack(side="left") - - # Info sections - sections = [ - ("Как подключить Telegram Desktop:", True), - (" Автоматически:", True), - (f" ПКМ по иконке в трее → «Открыть в Telegram»", False), - (f" Или ссылка: {tg_url}", False), - ("\n Вручную:", True), - (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" SOCKS5 → {host} : {port} (без логина/пароля)", False), - ] - - for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel( - frame, - text=text, - font=(FONT_FAMILY, 13, weight), - text_color=TEXT_PRIMARY, - anchor="w", - justify="left", - ).pack(anchor="w", pady=1) - - # Spacer - ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() - - # Separator - ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, corner_radius=0).pack( - fill="x", pady=(0, 12) + root = create_ctk_root( + ctk, + title="TG WS Proxy", + width=w, + height=h, + theme=theme, + after_create=_apply_linux_ctk_window_icon, ) - # Checkbox - auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox( - frame, - text="Открыть прокси в Telegram сейчас", - variable=auto_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 16)) - - def on_ok(): + def on_done(open_tg: bool): FIRST_RUN_MARKER.touch() - open_tg = auto_var.get() root.destroy() if open_tg: _on_open_in_telegram() - ctk.CTkButton( - frame, - text="Начать", - width=180, - height=42, - font=(FONT_FAMILY, 15, "bold"), - corner_radius=10, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_ok, - ).pack(pady=(0, 0)) + populate_first_run_window( + ctk, root, theme, host=host, port=port, on_done=on_done) - root.protocol("WM_DELETE_WINDOW", on_ok) root.mainloop() diff --git a/macos.py b/macos.py index 46eb5cf..b91d83a 100644 --- a/macos.py +++ b/macos.py @@ -356,8 +356,9 @@ def restart_proxy(): # Menu callbacks def _on_open_in_telegram(_=None): + host = _config.get("host", DEFAULT_CONFIG["host"]) port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" + url = f"tg://socks?server={host}&port={port}" log.info("Opening %s", url) try: result = subprocess.call(['open', url]) diff --git a/pyproject.toml b/pyproject.toml index 0524036..f4897ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ Source = "https://github.com/Flowseal/tg-ws-proxy" Issues = "https://github.com/Flowseal/tg-ws-proxy/issues" [tool.hatch.build.targets.wheel] -packages = ["proxy"] +packages = ["proxy", "ui"] [tool.hatch.build.force-include] "windows.py" = "windows.py" diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..57b7b3a --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,4 @@ +""" +Интерфейс tray (CustomTkinter): тема, диалоги настроек, подсказки. +Ядро прокси — пакет `proxy`. +""" diff --git a/ui/ctk_theme.py b/ui/ctk_theme.py new file mode 100644 index 0000000..3980b21 --- /dev/null +++ b/ui/ctk_theme.py @@ -0,0 +1,88 @@ +""" +Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux). +Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any, Callable, Optional, Tuple + +# Размеры и отступы (единые для диалогов настроек и первого запуска) +CONFIG_DIALOG_SIZE: Tuple[int, int] = (420, 540) +CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (24, 20) +FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) +FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) + + +@dataclass(frozen=True) +class CtkTheme: + """Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста.""" + + tg_blue: str = "#3390ec" + tg_blue_hover: str = "#2b7cd4" + bg: str = "#ffffff" + field_bg: str = "#f0f2f5" + field_border: str = "#d6d9dc" + text_primary: str = "#000000" + text_secondary: str = "#707579" + ui_font_family: str = "Sans" + mono_font_family: str = "Monospace" + + +def ctk_theme_for_platform() -> CtkTheme: + if sys.platform == "win32": + return CtkTheme(ui_font_family="Segoe UI", mono_font_family="Consolas") + return CtkTheme() + + +def apply_ctk_appearance(ctk: Any) -> None: + ctk.set_appearance_mode("light") + ctk.set_default_color_theme("blue") + + +def center_ctk_geometry(root: Any, width: int, height: int) -> None: + sw = root.winfo_screenwidth() + sh = root.winfo_screenheight() + root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}") + + +def create_ctk_root( + ctk: Any, + *, + title: str, + width: int, + height: int, + theme: CtkTheme, + topmost: bool = True, + after_create: Optional[Callable[[Any], None]] = None, +) -> Any: + """ + Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры. + after_create — опционально: установка иконки окна (различается по ОС). + """ + apply_ctk_appearance(ctk) + root = ctk.CTk() + root.title(title) + root.resizable(False, False) + if topmost: + root.attributes("-topmost", True) + center_ctk_geometry(root, width, height) + root.configure(fg_color=theme.bg) + if after_create: + after_create(root) + return root + + +def main_content_frame( + ctk: Any, + root: Any, + theme: CtkTheme, + *, + padx: int, + pady: int, +) -> Any: + frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0) + frame.pack(fill="both", expand=True, padx=padx, pady=pady) + return frame diff --git a/ui/ctk_tooltip.py b/ui/ctk_tooltip.py new file mode 100644 index 0000000..16c6da7 --- /dev/null +++ b/ui/ctk_tooltip.py @@ -0,0 +1,114 @@ +""" +Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import Any, List, Optional + + +class CtkTooltip: + """Показ текста при наведении на виджет.""" + + def __init__( + self, + widget: Any, + text: str, + *, + delay_ms: int = 450, + wraplength: int = 320, + ) -> None: + self.widget = widget + self.text = text + self.delay_ms = delay_ms + self.wraplength = wraplength + self._after_id: Optional[str] = None + self._tip: Optional[tk.Toplevel] = None + widget.bind("", self._schedule, add="+") + widget.bind("", self._hide, add="+") + widget.bind("