feat: проверка обновлений GitHub, пакет utils, зависимости по ОС

Проверка последнего релиза при запуске (опционально), диалог и ссылка на страницу релиза; секция в настройках CTk и меню macOS; единый default_tray_config; requirements/*.txt; README.

Refs: https://github.com/Flowseal/tg-ws-proxy/issues/357
This commit is contained in:
deexsed 2026-03-26 11:21:33 +03:00
parent 35ea33ee3f
commit f0044255fa
14 changed files with 492 additions and 31 deletions

View File

@ -40,10 +40,12 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
- **Выход** — остановить прокси и закрыть приложение
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
### macOS
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
@ -82,6 +84,11 @@ chmod +x TgWsProxy_linux_amd64
## Установка из исходников
Список пакетов `pip` с версиями **по ОС** (Windows/Linux/macOS, отдельно для Python 3.8 на Windows) — в каталоге [`requirements/`](requirements/README.md). Кратко:
- **Linux (трей):** системный пакет Tcl/Tk для `tkinter`, например `python3-tk` (Debian/Ubuntu).
- Дальше: `pip install -r requirements/<файл>.txt` и `pip install -e .` из корня репозитория.
### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
@ -94,6 +101,7 @@ tg-ws-proxy
### Windows 7/10+
```bash
pip install -r requirements/windows-py39plus.txt
pip install -e .
tg-ws-proxy-tray-win
```
@ -101,6 +109,7 @@ tg-ws-proxy-tray-win
### macOS
```bash
pip install -r requirements/macos.txt
pip install -e .
tg-ws-proxy-tray-macos
```
@ -108,6 +117,7 @@ tg-ws-proxy-tray-macos
### Linux
```bash
pip install -r requirements/linux.txt
pip install -e .
tg-ws-proxy-tray-linux
```
@ -179,15 +189,22 @@ Tray-приложение хранит данные в:
```json
{
"host": "127.0.0.1",
"port": 1080,
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
],
"verbose": false
"verbose": false,
"buf_kb": 256,
"pool_size": 4,
"log_max_mb": 5.0,
"check_updates": true
}
```
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
## Автоматическая сборка
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.

View File

@ -8,6 +8,7 @@ import os
import subprocess
import sys
import threading
import webbrowser
import time
from pathlib import Path
from typing import Dict, Optional
@ -20,6 +21,7 @@ from PIL import Image, ImageDraw, ImageFont
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
@ -44,15 +46,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
@ -350,6 +344,48 @@ def _show_info(text: str, title: str = "TG WS Proxy"):
root.destroy()
def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
r = _mb.askyesno(title, text, parent=root)
root.destroy()
return bool(r)
def _maybe_notify_update_async():
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 "?"
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 _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
@ -624,6 +660,8 @@ def run_tray():
start_proxy()
_maybe_notify_update_async()
_show_first_run()
_check_ipv6_warning()

View File

@ -31,6 +31,7 @@ except ImportError:
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
APP_NAME = "TgWsProxy"
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
@ -40,15 +41,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
@ -427,6 +420,55 @@ def _on_edit_config(_=None):
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _check_updates_menu_title() -> str:
on = bool(_config.get("check_updates", True))
return (
"✓ Проверять обновления при запуске"
if on
else "Проверять обновления при запуске (выкл)"
)
def _toggle_check_updates(_=None):
global _config
_config["check_updates"] = not bool(_config.get("check_updates", True))
save_config(_config)
if _app is not None:
_app._check_updates_item.title = _check_updates_menu_title()
def _on_open_release_page(_=None):
from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL)
def _maybe_notify_update_async():
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_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():
cfg = load_config()
@ -617,6 +659,12 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._logs_item = rumps.MenuItem(
"Открыть логи",
callback=_on_open_logs)
self._release_page_item = rumps.MenuItem(
"Страница релиза на GitHub…",
callback=_on_open_release_page)
self._check_updates_item = rumps.MenuItem(
_check_updates_menu_title(),
callback=_toggle_check_updates)
self._version_item = rumps.MenuItem(
f"Версия {__version__}",
callback=lambda _: None)
@ -633,6 +681,9 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._settings_item,
self._logs_item,
None,
self._release_page_item,
self._check_updates_item,
None,
self._version_item,
])
@ -672,6 +723,9 @@ def run_menubar():
return
start_proxy()
_maybe_notify_update_async()
_show_first_run()
_check_ipv6_warning()

View File

@ -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", "ui"]
packages = ["proxy", "ui", "utils"]
[tool.hatch.build.force-include]
"windows.py" = "windows.py"

66
requirements/README.md Normal file
View File

@ -0,0 +1,66 @@
# Зависимости по ОС (разработка из исходников)
Версии совпадают с [`pyproject.toml`](../pyproject.toml). Для установки **пакета проекта** после зависимостей выполните из корня репозитория:
```bash
pip install -e .
```
Скрипты точек входа: `tg-ws-proxy`, `tg-ws-proxy-tray-win` / `tg-ws-proxy-tray-linux` / `tg-ws-proxy-tray-macos`.
---
## Windows
| Файл | Условие |
|------|---------|
| [`windows-py39plus.txt`](windows-py39plus.txt) | Python **3.9+** (рекомендуется) |
| [`windows-py38.txt`](windows-py38.txt) | Python **3.8** |
**Системно:** для сборки/запуска из исходников — установленный [Python](https://www.python.org/downloads/) с опцией *tcl/tk* (обычно включена). Отдельный Tcl/Tk не требуется.
```powershell
pip install -r requirements\windows-py39plus.txt
pip install -e .
tg-ws-proxy-tray-win
```
---
## Linux
Файл: [`linux.txt`](linux.txt) (Python **3.9+**).
**Системно:** модуль `tkinter` (нужен CustomTkinter для трея):
- Debian/Ubuntu: `sudo apt install python3-tk`
- Fedora: `sudo dnf install python3-tkinter`
- Arch: `sudo pacman -S tk`
Также нужны заголовки Python при сборке расширений (если pip собирает колёса): `python3-dev` / `gcc` — по сообщениям pip.
```bash
pip install -r requirements/linux.txt
pip install -e .
tg-ws-proxy-tray-linux
```
---
## macOS
Файл: [`macos.txt`](macos.txt).
**Системно:** Python с официального установщика или Homebrew; Tcl/Tk обычно уже в комплекте. Для графики трея используется **rumps** (нативная строка меню), не CustomTkinter.
```bash
pip install -r requirements/macos.txt
pip install -e .
tg-ws-proxy-tray-macos
```
---
## Примечание
Готовые бинарники для пользователей — на [странице релизов](https://github.com/Flowseal/tg-ws-proxy/releases); отдельная установка Python и `pip` для них не требуется.

11
requirements/linux.txt Normal file
View File

@ -0,0 +1,11 @@
# Linux (не macOS), Python 3.9+ (как в pyproject.toml)
# Системно: пакет Tcl/Tk для tkinter (CustomTkinter), см. README в этой папке.
# Установка: pip install -r requirements/linux.txt
# Затем из корня репозитория: pip install -e .
pyperclip==1.9.0
psutil==7.0.0
cryptography==46.0.5
Pillow==12.1.1
customtkinter==5.2.2
pystray==0.19.5

9
requirements/macos.txt Normal file
View File

@ -0,0 +1,9 @@
# macOS, Python 3.9+ (без customtkinter/pystray — нативное меню rumps)
# Установка: pip install -r requirements/macos.txt
# Затем из корня репозитория: pip install -e .
pyperclip==1.9.0
psutil==7.0.0
cryptography==46.0.5
Pillow==12.1.0
rumps==0.4.0

View File

@ -0,0 +1,9 @@
# Windows, Python 3.8 (устаревшие пины из pyproject.toml)
# Установка: pip install -r requirements/windows-py38.txt
pyperclip==1.9.0
psutil==5.9.8
cryptography==41.0.7
Pillow==10.4.0
customtkinter==5.2.2
pystray==0.19.5

View File

@ -0,0 +1,10 @@
# Windows, Python 3.9+ (как в pyproject.toml для platform_system == Windows, python_version >= 3.9)
# Установка: pip install -r requirements/windows-py39plus.txt
# Затем из корня репозитория: pip install -e .
pyperclip==1.9.0
psutil==7.0.0
cryptography==46.0.5
Pillow==12.1.1
customtkinter==5.2.2
pystray==0.19.5

View File

@ -5,11 +5,13 @@
from __future__ import annotations
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.ctk_theme import (
FIRST_RUN_FRAME_PAD,
@ -52,6 +54,10 @@ _TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, запись автозапуска может сброситься."
)
_TIP_CHECK_UPDATES = (
"При запуске запрашивать с GitHub информацию о последнем релизе. "
"При появлении новой версии можно открыть страницу загрузки."
)
_TIP_SAVE = "Сохранить настройки в файл. После сохранения можно перезапустить прокси."
_TIP_CANCEL = "Закрыть окно без сохранения изменений."
@ -121,6 +127,7 @@ class TrayConfigFormWidgets:
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
def install_tray_config_form(
@ -291,6 +298,71 @@ def install_tray_config_form(
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status()
check_updates_var = ctk.BooleanVar(
value=bool(
cfg.get("check_updates", default_config.get("check_updates", True))
)
)
upd_cb = ctk.CTkCheckBox(
upd_inner,
text="Проверять обновления при запуске",
variable=check_updates_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске."
elif st.get("has_update") and st.get("latest"):
upd_status = (
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"):
upd_status = (
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
else:
upd_status = "Установлена последняя известная версия с GitHub."
ctk.CTkLabel(
upd_inner,
text=upd_status,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
open_rel_btn = ctk.CTkButton(
upd_inner,
text="Открыть страницу релиза",
height=32,
font=(theme.ui_font_family, 13),
corner_radius=8,
fg_color=theme.field_bg,
hover_color=theme.field_border,
text_color=theme.text_primary,
border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
)
open_rel_btn.pack(anchor="w")
autostart_var = None
if show_autostart:
sys_inner = _config_section(
@ -330,6 +402,7 @@ def install_tray_config_form(
adv_entries=adv_entries,
adv_keys=adv_keys,
autostart_var=autostart_var,
check_updates_var=check_updates_var,
)
@ -399,6 +472,8 @@ def validate_config_form(
)
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
return new_cfg

5
utils/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Вспомогательные утилиты (проверка релизов и т.п.)."""
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
__all__ = ["RELEASES_PAGE_URL", "get_status", "run_check"]

27
utils/default_config.py Normal file
View File

@ -0,0 +1,27 @@
"""
Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS).
Единственное отличие по платформе ключ autostart только на Windows.
"""
from __future__ import annotations
import sys
from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"check_updates": True,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON)
if sys.platform == "win32":
cfg["autostart"] = False
return cfg

102
utils/update_check.py Normal file
View File

@ -0,0 +1,102 @@
"""
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
"""
from __future__ import annotations
import json
from itertools import zip_longest
from typing import Any, Dict, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
_state: Dict[str, Any] = {
"checked": False,
"has_update": False,
"ahead_of_release": False,
"latest": None,
"html_url": None,
"error": None,
}
def _parse_version_tuple(s: str) -> tuple:
s = (s or "").strip().lstrip("vV")
if not s:
return (0,)
parts = []
for seg in s.split("."):
digits = "".join(c for c in seg if c.isdigit())
if digits:
try:
parts.append(int(digits))
except ValueError:
parts.append(0)
else:
parts.append(0)
return tuple(parts) if parts else (0,)
def _version_gt(a: str, b: str) -> bool:
"""True, если версия a новее b (простое сравнение по сегментам)."""
ta = _parse_version_tuple(a)
tb = _parse_version_tuple(b)
for x, y in zip_longest(ta, tb, fillvalue=0):
if x > y:
return True
if x < y:
return False
return False
def fetch_latest_release(timeout: float = 12.0) -> Optional[dict]:
req = Request(
RELEASES_LATEST_API,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
},
method="GET",
)
with urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
def run_check(current_version: str) -> None:
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
global _state
_state["checked"] = True
_state["error"] = None
try:
data = fetch_latest_release()
tag = (data.get("tag_name") or "").strip()
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url
return
latest_clean = tag.lstrip("vV")
cur = (current_version or "").strip().lstrip("vV")
_state["latest"] = latest_clean
_state["html_url"] = html_url
_state["has_update"] = _version_gt(latest_clean, cur)
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
cur, latest_clean
)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
_state["error"] = str(e)
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = RELEASES_PAGE_URL
def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state)

View File

@ -38,6 +38,7 @@ except ImportError:
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
@ -65,16 +66,7 @@ FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = {
"port": 1080,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"autostart": False,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
}
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
@ -404,6 +396,50 @@ 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_ICONQUESTION = 0x20
IDYES = 6
text = (
f"Доступна новая версия: {latest_version}\n\n"
f"Открыть страницу релиза в браузере?"
)
r = _user32.MessageBoxW(
None,
text,
"TG WS Proxy — обновление",
MB_YESNO | MB_ICONQUESTION,
)
return r == IDYES
def _maybe_notify_update_async():
"""
Фоновая проверка GitHub Releases и уведомление (не блокирует трей).
"""
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()
def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
@ -705,6 +741,8 @@ def run_tray():
start_proxy()
_maybe_notify_update_async()
_show_first_run()
_check_ipv6_warning()