Compare commits

..

24 Commits

Author SHA1 Message Date
Flowseal 43bca3a71b may be fixes #1017 2026-06-23 18:45:20 +03:00
Flowseal 6b5fd72612 i18n fixes 2026-06-23 18:24:26 +03:00
Kirill 85b5e7f22a feature: i18n (#1025) 2026-06-23 15:41:49 +03:00
Flowseal fed772049b session close reason 2026-06-23 14:55:25 +03:00
Flowseal 91d39a5ebe Revert "fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925)" (#1036)
This reverts commit 96e5b4b639.
2026-06-23 14:47:12 +03:00
Flowseal 5cbac657dc CfWorker pool age tune 2026-06-23 13:46:54 +03:00
delewer ee6c34e065 ci: add better view on macos installation (#1019) 2026-06-23 13:36:20 +03:00
Flowseal ce6a456bd1 docs update 2026-06-23 13:24:49 +03:00
Flowseal 5bc5001c4d SHA256 compare fix 2026-06-18 15:22:30 +03:00
Flowseal 2afd80825b docs update 2026-06-17 11:33:20 +03:00
Flowseal 12fafbc8f4 Version bump 2026-06-17 11:05:17 +03:00
Flowseal 5839ca2564 Portable mode 2026-06-17 11:02:04 +03:00
Flowseal e40c571009 #646 fixes 2026-06-17 10:25:45 +03:00
Konukhov Yaroslav 96e5b4b639 fix: add WebSocket keepalive pings to prevent idle disconnects (#646) (#925) 2026-06-17 10:13:55 +03:00
Flowseal 13d2b1db6d #943 fixes 2026-06-17 09:54:53 +03:00
Yan a29a1a8610 Добавлена сборка Windows ARM64 в Build & Release workflow (#943) 2026-06-17 09:50:32 +03:00
partoftheworlD 94010f1481 Added support for cf worker for docker container (#996) 2026-06-17 09:45:25 +03:00
Kenyka Kenykovich 42172235c7 Исправлен fallback список CF proxy доменов (добавлена запятая) (#958) 2026-06-17 09:44:00 +03:00
Flowseal b0010af130 #924 improvements 2026-06-17 09:43:06 +03:00
Konukhov Yaroslav 784a7f659b fix: diagnose permission and bad-address bind failures on startup (#924) 2026-06-17 09:24:44 +03:00
Konukhov Yaroslav 21fe672963 fix: rotate log files instead of growing without bound (#885) (#932) 2026-06-17 09:13:32 +03:00
Flowseal ed46ecce5a version bump 2026-06-03 17:14:12 +03:00
Flowseal 9562b11101 docs 2026-06-03 17:13:47 +03:00
Flowseal dfdb993da5 shuffle cfworker domains 2026-06-03 17:09:16 +03:00
32 changed files with 1424 additions and 448 deletions
+10
View File
@@ -8,3 +8,13 @@ clngqrflngqin.com
tjacxbqtj.com tjacxbqtj.com
bxaxtxmrw.com bxaxtxmrw.com
dmohrsgmohcrwb.com dmohrsgmohcrwb.com
vwbmtmoi.com
khgrre.com
ulihssf.com
tmhqsdqmfpmk.com
xwuwoqbm.com
orgcnunpj.com
zhkuldz.com
zypoljnslxa.com
efabnxaowuzs.com
zaftuzsftqdq.com
+110 -39
View File
@@ -17,7 +17,7 @@ permissions:
contents: write contents: write
jobs: jobs:
build-windows: build-windows-x64:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -73,9 +73,85 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: TgWsProxy name: TgWsProxy-windows-x64
path: dist/TgWsProxy_windows.exe path: dist/TgWsProxy_windows.exe
build-windows-arm64:
runs-on: windows-11-arm
env:
CRYPTOGRAPHY_VERSION: "46.0.5"
ARM64_WHEELHOUSE: wheelhouse-arm64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
architecture: arm64
cache: "pip"
- name: Restore ARM64 cryptography wheel
id: cryptography-wheel-cache
uses: actions/cache@v4
with:
path: ${{ env.ARM64_WHEELHOUSE }}
key: windows-arm64-py311-cryptography-${{ env.CRYPTOGRAPHY_VERSION }}-${{ hashFiles('pyproject.toml', '.github/workflows/build.yml') }}
- name: Install ARM64 OpenSSL
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
vcpkg install openssl:arm64-windows-static
$opensslDir = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows-static"
"OPENSSL_DIR=$opensslDir" >> $env:GITHUB_ENV
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
- name: Build ARM64 cryptography wheel
if: steps.cryptography-wheel-cache.outputs.cache-hit != 'true'
run: |
mkdir $env:ARM64_WHEELHOUSE
pip wheel --no-deps --wheel-dir $env:ARM64_WHEELHOUSE "cryptography==$env:CRYPTOGRAPHY_VERSION"
- name: Install dependencies & pyinstaller
run: pip install --find-links $env:ARM64_WHEELHOUSE . "pyinstaller==6.13.0"
- name: Build EXE with PyInstaller
run: pyinstaller packaging/windows.spec --noconfirm
- name: Strip Rich PE header
shell: bash
run: |
python -c "
import struct, pathlib
exe = pathlib.Path('dist/TgWsProxy.exe')
data = bytearray(exe.read_bytes())
rich = data.find(b'Rich')
if rich == -1:
print('Rich header not found, skipping')
raise SystemExit(0)
ck = struct.unpack_from('<I', data, rich + 4)[0]
dans = struct.pack('<I', 0x536E6144 ^ ck)
ds = data.find(dans)
if ds == -1:
print('DanS marker not found, skipping')
raise SystemExit(0)
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
exe.write_bytes(data)
print(f'Stripped Rich header: offset {ds}..{rich+8}')
"
- name: Rename artifact
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: TgWsProxy-windows-arm64
path: dist/TgWsProxy_windows_arm64.exe
build-win7: build-win7:
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
@@ -196,30 +272,10 @@ jobs:
python3.12 -m pip install . python3.12 -m pip install .
python3.12 -m pip install pyinstaller==6.13.0 python3.12 -m pip install pyinstaller==6.13.0
- name: Create macOS icon from ICO - name: Create macOS icon
run: | run: |
set -euo pipefail set -euo pipefail
python3.12 - <<'PY' python3.12 macos.py --render-app-icon icon.icns
from PIL import Image
image = Image.open('icon.ico')
image = image.resize((1024, 1024), Image.LANCZOS)
image.save('icon_1024.png', 'PNG')
PY
mkdir -p icon.iconset
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
iconutil -c icns icon.iconset -o icon.icns
rm -rf icon.iconset icon_1024.png
- name: Build app with PyInstaller - name: Build app with PyInstaller
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
@@ -227,6 +283,11 @@ jobs:
- name: Validate universal2 app bundle - name: Validate universal2 app bundle
run: | run: |
set -euo pipefail set -euo pipefail
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
'dist/TG WS Proxy.app/Contents/Info.plist')"
test -n "$ICON_FILE"
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
found=0 found=0
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
if file "$file" | grep -q "Mach-O"; then if file "$file" | grep -q "Mach-O"; then
@@ -250,22 +311,31 @@ jobs:
- name: Create DMG - name: Create DMG
run: | run: |
set -euo pipefail set -euo pipefail
APP_NAME="TG WS Proxy" packaging/dmg/build_dmg.sh \
DMG_TEMP="dist/dmg_temp" "dist/TG WS Proxy.app" \
"TG WS Proxy" \
rm -rf "$DMG_TEMP"
mkdir -p "$DMG_TEMP"
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
ln -s /Applications "$DMG_TEMP/Applications"
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$DMG_TEMP" \
-ov \
-format UDZO \
"dist/TgWsProxy_macos_universal.dmg" "dist/TgWsProxy_macos_universal.dmg"
rm -rf "$DMG_TEMP" - name: Validate DMG
run: |
set -euo pipefail
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
MOUNT_DIR="$(mktemp -d)"
DEVICE="$(hdiutil attach \
-readonly \
-nobrowse \
-mountpoint "$MOUNT_DIR" \
"$DMG" \
| awk '/^\/dev\// { print $1; exit }')"
test -d "$MOUNT_DIR/TG WS Proxy.app"
test -L "$MOUNT_DIR/Applications"
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
test -f "$MOUNT_DIR/.background/background.tiff"
test -f "$MOUNT_DIR/.DS_Store"
hdiutil detach "$DEVICE"
rmdir "$MOUNT_DIR"
done
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -439,7 +509,7 @@ jobs:
dist/TgWsProxy_linux_amd64.rpm dist/TgWsProxy_linux_amd64.rpm
release: release:
needs: [build-windows, build-win7, build-macos, build-linux] needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }} if: ${{ github.event.inputs.make_release == 'true' }}
steps: steps:
@@ -463,6 +533,7 @@ jobs:
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/ > Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
files: | files: |
dist/TgWsProxy_windows.exe dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_arm64.exe
dist/TgWsProxy_windows_7_64bit.exe dist/TgWsProxy_windows_7_64bit.exe
dist/TgWsProxy_windows_7_32bit.exe dist/TgWsProxy_windows_7_32bit.exe
dist/TgWsProxy_macos_universal.dmg dist/TgWsProxy_macos_universal.dmg
+3 -2
View File
@@ -25,7 +25,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
TG_WS_PROXY_HOST=0.0.0.0 \ TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1443 \ TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_SECRET="" \ TG_WS_PROXY_SECRET="" \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
TG_WS_PROXY_CF_WORKER=""
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends tini ca-certificates \ && apt-get install -y --no-install-recommends tini ca-certificates \
@@ -42,5 +43,5 @@ USER app
EXPOSE 1443/tcp EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; if [ -n \"${TG_WS_PROXY_CF_WORKER}\" ]; then args=\"$args --cfproxy-worker-domain ${TG_WS_PROXY_CF_WORKER}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD [] CMD []
+6 -5
View File
@@ -36,11 +36,12 @@ tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
Все настройки задаются переменными окружения при запуске контейнера: Все настройки задаются переменными окружения при запуске контейнера:
| Переменная | Описание | По умолчанию | | Переменная | Описание | По умолчанию |
|-----------------------|------------------------------------------------|--------------------------------------| | ----------------------- | --------------------------------- | ------------------------------------- |
| `TG_WS_PROXY_HOST` | Адрес для приёма подключений | `0.0.0.0` | | `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
| `TG_WS_PROXY_PORT` | Порт внутри контейнера | `1443` | | `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
| `TG_WS_PROXY_SECRET` | Секретный ключ | `random` | | `TG_WS_PROXY_SECRET` | `Секретный ключ` | `random` |
| `TG_WS_PROXY_DC_IPS` | Пары «номер DC:IP» через пробел | `2:149.154.167.220 4:149.154.167.220`| | `TG_WS_PROXY_DC_IPS` | `Пары «номер DC:IP» через пробел` | `2:149.154.167.220 4:149.154.167.220` |
| `TG_WS_PROXY_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
Пример с ручным указанием секрета: Пример с ручным указанием секрета:
+5 -3
View File
@@ -49,13 +49,14 @@
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md) - [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
- [Файлы конфигурации Tray-приложения](./TrayConfig.md) - [Файлы конфигурации Tray-приложения](./TrayConfig.md)
- [Установка из исходников](./BuildFromSource.md) - [Установка из исходников](./BuildFromSource.md)
- [Руководство для контрибьюторов](../CONTRIBUTING.md) - [Руководство для контрибьюторов](./CONTRIBUTING.md)
## Windows: быстрый вход ## Windows: быстрый вход
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -116,7 +117,8 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
Минимально поддерживаемые версии ОС для текущих бинарных сборок: Минимально поддерживаемые версии ОС для текущих бинарных сборок:
- Windows 10+ для `TgWsProxy_windows.exe` - Windows 10+ x64 для `TgWsProxy_windows.exe`
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe` - Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe` - Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
- Intel macOS 10.15+ - Intel macOS 10.15+
+6 -1
View File
@@ -2,7 +2,8 @@
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте: Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+) - `TgWsProxy_windows.exe` (Windows 10+ x64)
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64) - `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32) - `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
@@ -42,6 +43,10 @@
- **Порт:** `1443` (или переопределенный вами) - **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов - **Secret:** из настроек или логов
## Портативный режим
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
## Установка из исходников ## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md) Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
+47 -30
View File
@@ -30,6 +30,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
) )
from ui.i18n import set_language, t
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
@@ -53,16 +54,16 @@ def _msgbox(kind: str, text: str, title: str, **kw):
return result return result
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: Optional[str] = None) -> None:
_msgbox("showerror", text, title) _msgbox("showerror", text, title or t("app.error_title"))
def _show_info(text: str, title: str = "TG WS Proxy") -> None: def _show_info(text: str, title: Optional[str] = None) -> None:
_msgbox("showinfo", text, title) _msgbox("showinfo", text, title or t("app.name"))
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return bool(_msgbox("askyesno", text, title)) return bool(_msgbox("askyesno", text, title or t("app.name")))
def _apply_window_icon(root) -> None: def _apply_window_icon(root) -> None:
@@ -80,12 +81,10 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
log.info("Copying %s", url) log.info("Copying %s", url)
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(t("dialog.copy_ok", url=url))
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\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(t("dialog.copy_fail", error=exc))
def _on_copy_link(icon=None, item=None) -> None: def _on_copy_link(icon=None, item=None) -> None:
@@ -95,7 +94,7 @@ def _on_copy_link(icon=None, item=None) -> None:
pyperclip.copy(url) pyperclip.copy(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(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None) -> None: def _on_restart(icon=None, item=None) -> None:
@@ -118,7 +117,7 @@ def _on_open_logs(icon=None, item=None) -> None:
stdin=subprocess.DEVNULL, start_new_session=True, stdin=subprocess.DEVNULL, start_new_session=True,
) )
else: else:
_show_info("Файл логов ещё не создан.") _show_info(t("dialog.log_not_found"))
def _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
@@ -139,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
def _edit_config_dialog() -> None: def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.") _show_error(t("dialog.ctk_missing"))
return return
cfg = dict(_config) cfg = dict(_config)
@@ -148,41 +147,59 @@ def _edit_config_dialog() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=_apply_window_icon, after_create=_apply_window_icon,
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
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(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
on_language_change=_refresh_tray_menu,
)
_original_appearance = ctk.get_appearance_mode() _original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None: def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance) ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish() _finish()
def on_save() -> None: def on_save() -> None:
from tkinter import messagebox from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str): if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
_ui_only_keys = {"appearance", "check_updates"} _ui_only_keys = {"appearance", "check_updates", "language"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged) config_changed = any(merged.get(k) != _config.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed: if not config_changed:
_restore_ui_locale()
_finish() _finish()
return return
save_config(merged) save_config(merged)
_config.update(merged) _config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged) log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu() _tray_icon.menu = _build_menu()
@@ -191,8 +208,8 @@ def _edit_config_dialog() -> None:
return return
do_restart = messagebox.askyesno( do_restart = messagebox.askyesno(
"Перезапустить?", t("dialog.restart_title"),
"Настройки сохранены.\n\nПерезапустить прокси сейчас?", t("dialog.restart_body"),
parent=root, parent=root,
) )
_finish() _finish()
@@ -224,7 +241,7 @@ def _show_first_run() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=_apply_window_icon, after_create=_apply_window_icon,
) )
@@ -248,14 +265,14 @@ def _build_menu():
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit), pystray.MenuItem(t("tray.exit"), _on_exit),
) )
@@ -283,7 +300,7 @@ def run_tray() -> None:
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@@ -293,7 +310,7 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if not acquire_lock(): if not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
+47 -29
View File
@@ -9,16 +9,53 @@ import webbrowser
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
try:
import rumps
except ImportError:
rumps = None
try: try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
except ImportError: except ImportError:
Image = ImageDraw = ImageFont = None Image = ImageDraw = ImageFont = None
def render_app_icon(size: int):
scale = size / 1024
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
draw.ellipse(outer, fill=(0, 151, 221, 255))
try:
font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc",
round(430 * scale),
)
except Exception:
font = ImageFont.load_default()
box = draw.textbbox((0, 0), "T", font=font)
width = box[2] - box[0]
height = box[3] - box[1]
draw.text(
(
(size - width) / 2 - box[0],
(size - height) / 2 - box[1] - round(10 * scale),
),
"T",
font=font,
fill=(255, 255, 255, 255),
)
return image
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
if Image is None:
raise SystemExit("Pillow is required to render the macOS app icon")
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
render_app_icon(1024).save(output_path, format="ICNS")
raise SystemExit(0)
try:
import rumps
except ImportError:
rumps = None
try: try:
import pyperclip import pyperclip
except ImportError: except ImportError:
@@ -32,6 +69,7 @@ from utils.tray_common import (
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config, LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url, log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
) )
from utils.diagnostics import diagnose_listen_error
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
@@ -143,26 +181,10 @@ def _ask_cfworker_domain(default: str) -> Optional[str]:
def _make_menubar_icon(size: int = 44): def _make_menubar_icon(size: int = 44):
if Image is None: if Image is None:
return None return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) return render_app_icon(size)
draw = ImageDraw.Draw(img)
margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 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]
draw.text(
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
"T", fill=(255, 255, 255, 255), font=font,
)
return img
def _ensure_menubar_icon() -> None: def _ensure_menubar_icon() -> None:
if MENUBAR_ICON_PATH.exists():
return
ensure_dirs() ensure_dirs()
img = _make_menubar_icon(44) img = _make_menubar_icon(44)
if img: if img:
@@ -184,13 +206,9 @@ def _run_proxy_thread() -> None:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", exc) log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc): msg, _ = diagnose_listen_error(exc)
_show_error( if msg:
"Не удалось запустить прокси:\n" _show_error(msg)
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="${1:?Usage: build_dmg.sh <App.app> <Volume Name> <output.dmg> [assets_dir]}"
VOL_NAME="${2:?missing volume name}"
OUT_DMG="${3:?missing output dmg path}"
ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}"
WIN_W=660
WIN_H=440
ICON_SIZE=128
APP_X=145
APPS_X=515
ICON_Y=220
APP_NAME="$(basename "$APP_PATH")"
WORK="$(mktemp -d)"
STAGE="$WORK/stage"
RW_DMG="$WORK/rw.dmg"
MOUNT="/Volumes/$VOL_NAME"
DEVICE=""
cleanup() {
if [ -n "$DEVICE" ]; then
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true
fi
rm -rf "$WORK"
}
trap cleanup EXIT
mkdir -p "$STAGE/.background"
cp -R "$APP_PATH" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
tiffutil -cathidpicheck \
"$ASSETS_DIR/background-light.png" \
"$ASSETS_DIR/background-light@2x.png" \
-out "$STAGE/.background/background.tiff"
hdiutil create \
-volname "$VOL_NAME" \
-srcfolder "$STAGE" \
-fs HFS+ \
-format UDRW \
-ov \
"$RW_DMG"
DEVICE="$(hdiutil attach \
-readwrite \
-noverify \
-noautoopen \
-mountpoint "$MOUNT" \
"$RW_DMG" \
| awk '/^\/dev\// { print $1; exit }')"
test -n "$DEVICE"
test -d "$MOUNT/$APP_NAME"
sleep 2
osascript <<APPLESCRIPT
tell application "Finder"
tell disk "$VOL_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 140, 200 + $WIN_W, 140 + $WIN_H}
set theViewOptions to the icon view options of container window
set arrangement of theViewOptions to not arranged
set icon size of theViewOptions to $ICON_SIZE
set text size of theViewOptions to 13
set background picture of theViewOptions to file ".background:background.tiff"
set position of item "$APP_NAME" of container window to {$APP_X, $ICON_Y}
set position of item "Applications" of container window to {$APPS_X, $ICON_Y}
close
open
update
delay 2
end tell
end tell
APPLESCRIPT
SetFile -a C "$MOUNT" 2>/dev/null || true
sync
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \
|| { sleep 3; hdiutil detach "$DEVICE" -force; }
DEVICE=""
rm -f "$OUT_DMG"
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG"
echo "Created $OUT_DMG"
+3 -1
View File
@@ -12,6 +12,8 @@ block_cipher = None
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
# Collect gi (PyGObject) submodules and data so pystray._appindicator works # Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi') gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi') gi_datas = collect_data_files('gi')
@@ -26,7 +28,7 @@ a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas, datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + gi_datas + typelib_datas,
hiddenimports=[ hiddenimports=[
'pystray._appindicator', 'pystray._appindicator',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
+3 -1
View File
@@ -5,11 +5,13 @@ import os
block_cipher = None block_cipher = None
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[(_i18n_path, 'ui/i18n')],
hiddenimports=[ hiddenimports=[
'rumps', 'rumps',
'objc', 'objc',
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx # http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 7, 1, 0), filevers=(1, 7, 3, 0),
prodvers=(1, 7, 1, 0), prodvers=(1, 7, 3, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[ [
StringStruct(u'CompanyName', u'Flowseal'), StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'), StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.7.1.0'), StringStruct(u'FileVersion', u'1.7.3.0'),
StringStruct(u'InternalName', u'TgWsProxy'), StringStruct(u'InternalName', u'TgWsProxy'),
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'), StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'), StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
StringStruct(u'ProductName', u'TG WS Proxy'), StringStruct(u'ProductName', u'TG WS Proxy'),
StringStruct(u'ProductVersion', u'1.7.1.0'), StringStruct(u'ProductVersion', u'1.7.3.0'),
] ]
) )
] ]
+3 -1
View File
@@ -9,11 +9,13 @@ block_cipher = None
import customtkinter import customtkinter
ctk_path = os.path.dirname(customtkinter.__file__) ctk_path = os.path.dirname(customtkinter.__file__)
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[(ctk_path, 'customtkinter/')], datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')],
hiddenimports=[ hiddenimports=[
'pystray._win32', 'pystray._win32',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.7.1" __version__ = "1.7.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+20 -6
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import struct import struct
import random
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -180,6 +181,8 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
if not worker_domains: if not worker_domains:
return False return False
random.shuffle(worker_domains)
for worker_domain in worker_domains: for worker_domain in worker_domains:
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst) ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
if ws: if ws:
@@ -279,9 +282,10 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
up_packets = 0 up_packets = 0
down_packets = 0 down_packets = 0
start_time = asyncio.get_running_loop().time() start_time = asyncio.get_running_loop().time()
close_reason = 'normal'
async def tcp_to_ws(): async def tcp_to_ws():
nonlocal up_bytes, up_packets nonlocal up_bytes, up_packets, close_reason
try: try:
while True: while True:
chunk = await reader.read(65536) chunk = await reader.read(65536)
@@ -307,17 +311,22 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
await ws.send(parts[0]) await ws.send(parts[0])
else: else:
await ws.send(chunk) await ws.send(chunk)
except (asyncio.CancelledError, ConnectionError, OSError): except asyncio.CancelledError:
return return
except (ConnectionError, OSError) as e:
close_reason = f"client: {type(e).__name__}"
except Exception as e: except Exception as e:
close_reason = f"client: {type(e).__name__}: {e}"
log.debug("[%s] tcp->ws ended: %s", label, e) log.debug("[%s] tcp->ws ended: %s", label, e)
async def ws_to_tcp(): async def ws_to_tcp():
nonlocal down_bytes, down_packets nonlocal down_bytes, down_packets, close_reason
try: try:
while True: while True:
data = await ws.recv() data = await ws.recv()
if data is None: if data is None:
if close_reason == 'normal':
close_reason = 'upstream: ws_close'
break break
n = len(data) n = len(data)
stats.bytes_down += n stats.bytes_down += n
@@ -327,9 +336,14 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
data = ctx.clt_enc.update(plain) data = ctx.clt_enc.update(plain)
writer.write(data) writer.write(data)
await writer.drain() await writer.drain()
except (asyncio.CancelledError, ConnectionError, OSError): except asyncio.CancelledError:
return return
except (ConnectionError, OSError) as e:
close_reason = f"upstream: {type(e).__name__}"
except asyncio.IncompleteReadError:
close_reason = 'upstream: tcp_reset'
except Exception as e: except Exception as e:
close_reason = f"upstream: {type(e).__name__}: {e}"
log.debug("[%s] ws->tcp ended: %s", label, e) log.debug("[%s] ws->tcp ended: %s", label, e)
tasks = [asyncio.create_task(tcp_to_ws()), tasks = [asyncio.create_task(tcp_to_ws()),
@@ -345,9 +359,9 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
except BaseException: except BaseException:
pass pass
elapsed = asyncio.get_running_loop().time() - start_time elapsed = asyncio.get_running_loop().time() - start_time
log.info("[%s] %s WS session closed: " log.info("[%s] %s WS session closed (%s): "
"^%s (%d pkts) v%s (%d pkts) in %.1fs", "^%s (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag, label, dc_tag, close_reason,
human_bytes(up_bytes), up_packets, human_bytes(up_bytes), up_packets,
human_bytes(down_bytes), down_packets, human_bytes(down_bytes), down_packets,
elapsed) elapsed)
+19 -3
View File
@@ -29,7 +29,17 @@ _CFPROXY_ENC: List[str] = [
'clngqrflngqin.com', 'clngqrflngqin.com',
'tjacxbqtj.com', 'tjacxbqtj.com',
'bxaxtxmrw.com', 'bxaxtxmrw.com',
'dmohrsgmohcrwb.com' 'dmohrsgmohcrwb.com',
'vwbmtmoi.com',
'khgrre.com',
'ulihssf.com',
'tmhqsdqmfpmk.com',
'xwuwoqbm.com',
'orgcnunpj.com',
'zhkuldz.com',
'zypoljnslxa.com',
'efabnxaowuzs.com',
'zaftuzsftqdq.com'
] ]
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107)) _S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
@@ -190,13 +200,19 @@ def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
dc_redirects: Dict[int, str] = {} dc_redirects: Dict[int, str] = {}
for entry in dc_ip_list: for entry in dc_ip_list:
if ':' not in entry: if ':' not in entry:
raise ValueError( err = ValueError(
f"Invalid --dc-ip format {entry!r}, expected DC:IP") f"Invalid --dc-ip format {entry!r}, expected DC:IP")
err.entry = entry
err.kind = "format"
raise err
dc_s, ip_s = entry.split(':', 1) dc_s, ip_s = entry.split(':', 1)
try: try:
dc_n = int(dc_s) dc_n = int(dc_s)
_socket.inet_aton(ip_s) _socket.inet_aton(ip_s)
except (ValueError, OSError): except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}") err = ValueError(f"Invalid --dc-ip {entry!r}")
err.entry = entry
err.kind = "invalid"
raise err
dc_redirects[dc_n] = ip_s dc_redirects[dc_n] = ip_s
return dc_redirects return dc_redirects
+1 -1
View File
@@ -111,7 +111,7 @@ class _WsPool:
class _CfWorkerPool: class _CfWorkerPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 100.0
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, str], deque] = {} self._idle: Dict[Tuple[int, str], deque] = {}
+25
View File
@@ -1,5 +1,6 @@
import os import os
import ssl import ssl
import logging
import base64 import base64
import struct import struct
import asyncio import asyncio
@@ -8,6 +9,8 @@ import socket as _socket
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from .config import proxy_config from .config import proxy_config
log = logging.getLogger('tg-mtproto-proxy')
_st_BB = struct.Struct('>BB') _st_BB = struct.Struct('>BB')
_st_BBH = struct.Struct('>BBH') _st_BBH = struct.Struct('>BBH')
@@ -160,6 +163,9 @@ class RawWebSocket:
if opcode == self.OP_CLOSE: if opcode == self.OP_CLOSE:
self._closed = True self._closed = True
code, reason = self._parse_close(payload)
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
code, reason)
try: try:
self.writer.write(self._build_frame( self.writer.write(self._build_frame(
self.OP_CLOSE, self.OP_CLOSE,
@@ -202,6 +208,25 @@ class RawWebSocket:
except Exception: except Exception:
pass pass
_WS_CLOSE_REASONS = {
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
1011: 'internal_error',
}
@classmethod
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
if not payload or len(payload) < 2:
return None, ''
try:
code = int.from_bytes(payload[:2], 'big')
text = payload[2:].decode('utf-8', errors='replace')
name = cls._WS_CLOSE_REASONS.get(code)
return code, f"{text} ({name})" if name else text
except Exception:
return None, ''
@staticmethod @staticmethod
def _build_frame(opcode: int, data: bytes, def _build_frame(opcode: int, data: bytes,
mask: bool = False) -> bytes: mask: bool = False) -> bytes:
+14 -8
View File
@@ -521,14 +521,19 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
if stop_task in done: if stop_task in done:
server.close() for task in list(_client_tasks):
await server.wait_closed() task.cancel()
if _client_tasks:
await asyncio.gather(
*_client_tasks, return_exceptions=True)
if not serve_task.done(): if not serve_task.done():
serve_task.cancel() serve_task.cancel()
try: try:
await serve_task await serve_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
server.close()
await server.wait_closed()
else: else:
stop_task.cancel() stop_task.cancel()
try: try:
@@ -568,8 +573,9 @@ def main():
help='Log to file with rotation (default: stderr only)') help='Log to file with rotation (default: stderr only)')
ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB',
help='Max log file size in MB before rotation (default 5)') help='Max log file size in MB before rotation (default 5)')
ap.add_argument('--log-backups', type=int, default=0, metavar='N', ap.add_argument('--log-backups', type=int, default=1, metavar='N',
help='Number of rotated log files to keep (default 0)') help='Number of rotated log files to keep (min 1; '
'rotation needs at least one backup to bound size)')
ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', ap.add_argument('--buf-kb', type=int, default=256, metavar='KB',
help='Socket send/recv buffer size in KB (default 256)') help='Socket send/recv buffer size in KB (default 256)')
ap.add_argument('--pool-size', type=int, default=4, metavar='N', ap.add_argument('--pool-size', type=int, default=4, metavar='N',
@@ -640,11 +646,11 @@ def main():
root.addHandler(console) root.addHandler(console)
if args.log_file: if args.log_file:
fh = logging.handlers.RotatingFileHandler( from utils.logging_setup import build_log_handler
fh = build_log_handler(
args.log_file, args.log_file,
maxBytes=max(32 * 1024, int(args.log_max_mb * 1024 * 1024)), log_max_mb=args.log_max_mb,
backupCount=max(0, args.log_backups), backups=args.log_backups,
encoding='utf-8',
) )
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
root.addHandler(fh) root.addHandler(fh)
+218 -185
View File
@@ -17,63 +17,16 @@ from ui.ctk_theme import (
main_content_frame, main_content_frame,
) )
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
from ui.i18n import (
label_from_language,
language_from_label,
language_option_labels,
set_language,
t,
)
log = logging.getLogger('tg-mtproto-proxy') log = logging.getLogger('tg-mtproto-proxy')
_TIP_HOST = (
"Адрес, на котором прокси принимает подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
)
_TIP_PORT = (
"Порт прокси. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт"
)
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 4:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n"
"Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220"
)
_TIP_VERBOSE = (
"Если включено, в файл логов пишется больше подробностей — "
"необходимо при поиске неполадок"
)
_TIP_BUF_KB = (
"Размер буфера приёма/передачи в килобайтах.\n"
"Больше значение — больше выделение памяти на сокет"
)
_TIP_POOL = (
"Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n"
"Увеличение может помочь при высокой нагрузке"
)
_TIP_LOG_MB = (
"Максимальный размер файла лога; при достижении лимита файл перезаписывается"
)
_TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуск сбросится"
)
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_CFPROXY = (
"Использовать Cloudflare прокси для недоступных датацентров"
)
_TIP_CFPROXY_DOMAIN = (
"Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\n"
"Несколько доменов указывайте через запятую.\n"
"Если не указаны — выбираются автоматически из поддерживаемых доменов"
)
_TIP_CFPROXY_USER_DOMAIN_CB = (
"Указать свои домены вместо автоматического выбора"
)
_TIP_CFWORKER_DOMAIN = (
"Домены Cloudflare Worker (например, name.account.workers.dev).\n"
"Несколько доменов указывайте через запятую.\n"
"Прокси передает через них подключение к Telegram DC по IP"
)
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md" _CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md" _CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203] _CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
@@ -123,11 +76,11 @@ def _run_connectivity_test(cases: list) -> dict:
if "101" in first: if "101" in first:
results[dc] = True results[dc] = True
else: else:
results[dc] = first or "нет ответа" results[dc] = first or t("connectivity.no_response")
ssock.close() ssock.close()
raw.close() raw.close()
except _socket.timeout: except _socket.timeout:
results[dc] = "таймаут" results[dc] = t("connectivity.timeout")
except OSError as exc: except OSError as exc:
msg = str(exc) msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg results[dc] = msg[:60] if len(msg) > 60 else msg
@@ -183,30 +136,34 @@ def _show_connectivity_results(title_base: str, results: dict,
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
ok = [dc for dc, v in results.items() if v is True] ok = [dc for dc, v in results.items() if v is True]
total = len(_CFPROXY_TEST_DCS)
if auto_mode: if auto_mode:
if domain: if domain:
title = f"{title_base}: доступен" title = t("connectivity.available", title=title_base)
msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны." msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
else: else:
title = f"{title_base}: недоступен" title = t("connectivity.unavailable", title=title_base)
msg = unavailable_message msg = unavailable_message
else: else:
fail = [(dc, v) for dc, v in results.items() if v is not True] fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == len(_CFPROXY_TEST_DCS): if len(ok) == total:
title = f"{title_base}: всё работает" title = t("connectivity.all_ok", title=title_base)
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}." msg = t("connectivity.all_ok_domain", total=total, domain=domain)
elif not ok: elif not ok:
title = f"{title_base}: недоступен" title = t("connectivity.unavailable", title=title_base)
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n" errors = "\n".join(
msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail) t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
else: for dc, v in fail
title = f"{title_base}: частично работает"
msg = (
f"Домен: {domain}\n\n"
f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n"
f"\u2717 Недоступны:\n"
+ "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail)
) )
msg = t("connectivity.none_ok", domain=domain, errors=errors)
else:
title = t("connectivity.partial", title=title_base)
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = "\n".join(
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v)
for dc, v in fail
)
msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list)
root = _tk.Tk() root = _tk.Tk()
root.withdraw() root.withdraw()
@@ -232,26 +189,25 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict,
fail = [(dc, v) for dc, v in results.items() if v is not True] fail = [(dc, v) for dc, v in results.items() if v is not True]
if len(ok) == total: if len(ok) == total:
any_ok = True any_ok = True
blocks.append(f"\u2713 {domain}: все {total} серверов доступны") blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
elif not ok: elif not ok:
all_ok = False all_ok = False
blocks.append(f"\u2717 {domain}: недоступен") blocks.append(t("connectivity.multi_fail", domain=domain))
else: else:
all_ok = False all_ok = False
any_ok = True any_ok = True
ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok)
fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail)
blocks.append( blocks.append(
f"~ {domain}: работают " t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; "
f"недоступны "
f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}"
) )
if all_ok: if all_ok:
title = f"{title_base}: всё работает" title = t("connectivity.all_ok", title=title_base)
elif any_ok: elif any_ok:
title = f"{title_base}: частично работает" title = t("connectivity.partial", title=title_base)
else: else:
title = f"{title_base}: недоступен" title = t("connectivity.unavailable", title=title_base)
msg = "\n\n".join(blocks) msg = "\n\n".join(blocks)
root = _tk.Tk() root = _tk.Tk()
@@ -265,12 +221,32 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict,
_INNER_W = 396 _INNER_W = 396
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"] _APPEARANCE_KEYS = ("auto", "light", "dark")
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"} _APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _appearance_options() -> List[str]:
return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS]
def _appearance_from_cfg(value: str) -> str:
if value in _APPEARANCE_KEYS:
return t(f"appearance.{value}")
return t("appearance.auto")
def _appearance_to_cfg(label: str) -> str:
for key in _APPEARANCE_KEYS:
if t(f"appearance.{key}") == label:
return key
return "auto"
def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None:
combo.configure(values=[label for _, label in language_option_labels()])
var.set(label_from_language(cfg_value))
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw): def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict( opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius, font=(theme.ui_font_family, 13), corner_radius=radius,
@@ -374,6 +350,7 @@ class TrayConfigFormWidgets:
cfproxy_user_domain_var: Optional[Any] = None cfproxy_user_domain_var: Optional[Any] = None
cfproxy_worker_domain_var: Optional[Any] = None cfproxy_worker_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None appearance_var: Optional[Any] = None
language_var: Optional[Any] = None
def install_tray_config_form( def install_tray_config_form(
@@ -385,11 +362,15 @@ def install_tray_config_form(
*, *,
show_autostart: bool = False, show_autostart: bool = False,
autostart_value: bool = False, autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets: ) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg)
header = ctk.CTkFrame(frame, fg_color="transparent") header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2)) header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel( ctk.CTkLabel(
header, text="Настройки", header, text=t("settings.title"),
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w", text_color=theme.text_primary, anchor="w",
).pack(side="left") ).pack(side="left")
@@ -398,35 +379,16 @@ def install_tray_config_form(
font=(theme.ui_font_family, 12), font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, anchor="e", text_color=theme.text_secondary, anchor="e",
).pack(side="right", padx=(4, 0)) ).pack(side="right", padx=(4, 0))
appearance_var = ctk.StringVar( appearance_var = ctk.StringVar(
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто") value=_appearance_from_cfg(cfg.get("appearance", "auto"))
) )
def _on_appearance_change(choice: str) -> None: def _on_appearance_change(choice: str) -> None:
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto") cfg_val = _appearance_to_cfg(choice)
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val]) ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
cfg["appearance"] = cfg_val cfg["appearance"] = cfg_val
ctk.CTkComboBox(
header,
values=_APPEARANCE_OPTIONS,
variable=appearance_var,
width=102,
height=28,
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
fg_color=theme.field_bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
command=_on_appearance_change,
).pack(side="right")
ctk.CTkButton( ctk.CTkButton(
header, text="Donate ♥", width=90, height=28, header, text="Donate ♥", width=90, height=28,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8, font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
@@ -438,22 +400,88 @@ def install_tray_config_form(
), ),
).pack(side="right", padx=(0, 6)) ).pack(side="right", padx=(0, 6))
conn = _config_section(ctk, frame, theme, "Подключение MTProto") ui_inner = _config_section(ctk, frame, theme, t("section.interface"))
ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent")
ui_row.pack(fill="x")
lang_col = ctk.CTkFrame(ui_row, fg_color="transparent")
lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8))
theme_col = ctk.CTkFrame(ui_row, fg_color="transparent")
theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0))
language_var = ctk.StringVar(value=label_from_language(lang_cfg))
_label(ctk, lang_col, theme, t("settings.language"), size=11).pack(
anchor="w", pady=(0, 2)
)
language_combo = ctk.CTkComboBox(
lang_col,
values=[label for _, label in language_option_labels()],
variable=language_var,
height=32,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
fg_color=theme.bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
)
language_combo.pack(fill="x")
_sync_language_combobox(language_combo, language_var, lang_cfg)
def _on_language_change(choice: str) -> None:
lang = language_from_label(choice)
set_language(lang)
_sync_language_combobox(language_combo, language_var, lang)
if on_language_change is not None:
on_language_change()
language_combo.configure(command=_on_language_change)
_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack(
anchor="w", pady=(0, 2)
)
theme_combo = ctk.CTkComboBox(
theme_col,
values=_appearance_options(),
variable=appearance_var,
height=32,
font=(theme.ui_font_family, 12),
text_color=theme.text_primary,
fg_color=theme.bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
command=_on_appearance_change,
)
theme_combo.pack(fill="x")
conn = _config_section(ctk, frame, theme, t("section.mtproto"))
host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x") host_row.pack(fill="x")
host_col, host_var = _labeled_entry( host_col, host_var = _labeled_entry(
ctk, host_row, theme, "IP-адрес", ctk, host_row, theme, t("label.host"),
cfg.get("host", default_config["host"]), cfg.get("host", default_config["host"]),
tip=_TIP_HOST, width=160, pack_fill=True, tip=t("tip.host"), width=160, pack_fill=True,
) )
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_col, port_var = _labeled_entry( port_col, port_var = _labeled_entry(
ctk, host_row, theme, "Порт", ctk, host_row, theme, t("label.port"),
cfg.get("port", default_config["port"]), cfg.get("port", default_config["port"]),
tip=_TIP_PORT, width=100, tip=t("tip.port"), width=100,
) )
port_col.pack(side="left") port_col.pack(side="left")
@@ -461,9 +489,9 @@ def install_tray_config_form(
secret_row.pack(fill="x") secret_row.pack(fill="x")
secret_col, secret_var = _labeled_entry( secret_col, secret_var = _labeled_entry(
ctk, secret_row, theme, "Secret", ctk, secret_row, theme, t("label.secret"),
cfg.get("secret", default_config["secret"]), cfg.get("secret", default_config["secret"]),
tip=_TIP_SECRET, width=160, pack_fill=True, tip=t("tip.secret"), width=160, pack_fill=True,
) )
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
@@ -478,8 +506,8 @@ def install_tray_config_form(
command=lambda: secret_var.set(os.urandom(16).hex()), command=lambda: secret_var.set(os.urandom(16).hex()),
).pack() ).pack()
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11) dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
dc_lbl.pack(anchor="w", pady=(0, 4)) dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox( dc_textbox = ctk.CTkTextbox(
dc_inner, width=_INNER_W, height=88, dc_inner, width=_INNER_W, height=88,
@@ -489,9 +517,9 @@ def install_tray_config_form(
) )
dc_textbox.pack(fill="x") dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC) attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy") cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent") cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
cf_row.pack(fill="x", pady=(0, 4)) cf_row.pack(fill="x", pady=(0, 4))
@@ -499,9 +527,9 @@ def install_tray_config_form(
cfproxy_var = ctk.BooleanVar( cfproxy_var = ctk.BooleanVar(
value=cfg.get("cfproxy", default_config.get("cfproxy", True)) value=cfg.get("cfproxy", default_config.get("cfproxy", True))
) )
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var) cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16)) cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY) attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
_cf_test_btn = [None] _cf_test_btn = [None]
@@ -512,7 +540,7 @@ def install_tray_config_form(
) )
btn = _cf_test_btn[0] btn = _cf_test_btn[0]
if btn: if btn:
btn.configure(text="...", state="disabled") btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading import threading as _threading
if user_domains: if user_domains:
def _worker(): def _worker():
@@ -522,14 +550,14 @@ def install_tray_config_form(
btn.after( btn.after(
0, 0,
lambda: _show_multi_connectivity_results( lambda: _show_multi_connectivity_results(
"CF-прокси", per, label_prefix='kws', t("connectivity.cfproxy_title"), per, label_prefix='kws',
), ),
) )
except Exception as exc: except Exception as exc:
log.error("CF proxy test failed: %s", exc) log.error("CF proxy test failed: %s", exc)
finally: finally:
if btn: if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal")) btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
_threading.Thread(target=_worker, daemon=True).start() _threading.Thread(target=_worker, daemon=True).start()
else: else:
def _worker_auto(): def _worker_auto():
@@ -539,23 +567,21 @@ def install_tray_config_form(
btn.after( btn.after(
0, 0,
lambda: _show_connectivity_results( lambda: _show_connectivity_results(
"CF-прокси", res, t("connectivity.cfproxy_title"), res,
domain=ok_domain or '', domain=ok_domain or '',
auto_mode=True, auto_mode=True,
unavailable_message=( unavailable_message=t("connectivity.cf_auto_fail"),
"\u2717 Ни один из автоматических CF-доменов не отвечает."
),
), ),
) )
except Exception as exc: except Exception as exc:
log.error("CF proxy auto-test failed: %s", exc) log.error("CF proxy auto-test failed: %s", exc)
finally: finally:
if btn: if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal")) btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
_threading.Thread(target=_worker_auto, daemon=True).start() _threading.Thread(target=_worker_auto, daemon=True).start()
_cf_test_widget = ctk.CTkButton( _cf_test_widget = ctk.CTkButton(
cf_row, text="Тест", width=56, height=28, cf_row, text=t("button.test"), width=56, height=28,
font=(theme.ui_font_family, 13), corner_radius=8, font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border, text_color="#ffffff", border_width=1, border_color=theme.field_border,
@@ -571,9 +597,9 @@ def install_tray_config_form(
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
) )
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains)) cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var) cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10)) cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, _TIP_CFPROXY_USER_DOMAIN_CB) attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
ctk.CTkButton( ctk.CTkButton(
cf_custom_row, text="?", width=28, height=32, cf_custom_row, text="?", width=28, height=32,
@@ -589,7 +615,7 @@ def install_tray_config_form(
height=32, radius=8, height=32, radius=8,
) )
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN) attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
def _sync_domain_entry(*_): def _sync_domain_entry(*_):
state = "normal" if cf_custom_cb_var.get() else "disabled" state = "normal" if cf_custom_cb_var.get() else "disabled"
@@ -600,11 +626,11 @@ def install_tray_config_form(
cf_custom_cb_var.trace_add("write", _sync_domain_entry) cf_custom_cb_var.trace_add("write", _sync_domain_entry)
_sync_domain_entry() _sync_domain_entry()
cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker") cf_worker_inner = _config_section(ctk, frame, theme, t("section.cfworker"))
cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
cf_worker_row.pack(fill="x", pady=(0, 4)) cf_worker_row.pack(fill="x", pady=(0, 4))
cf_worker_lbl = _label(ctk, cf_worker_row, theme, "Cloudflare Worker домены (через запятую)", size=11) cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11)
cf_worker_lbl.pack(anchor="w", pady=(0, 2)) cf_worker_lbl.pack(anchor="w", pady=(0, 2))
cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
@@ -620,7 +646,7 @@ def install_tray_config_form(
height=32, radius=8, height=32, radius=8,
) )
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], _TIP_CFWORKER_DOMAIN) attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
_cfworker_test_btn = [None] _cfworker_test_btn = [None]
@@ -636,7 +662,7 @@ def install_tray_config_form(
btn = _cfworker_test_btn[0] btn = _cfworker_test_btn[0]
if not domains or btn is None: if not domains or btn is None:
return return
btn.configure(text="...", state="disabled") btn.configure(text=t("button.test_loading"), state="disabled")
import threading as _threading import threading as _threading
def _worker(): def _worker():
@@ -645,13 +671,13 @@ def install_tray_config_form(
btn.after( btn.after(
0, 0,
lambda: _show_multi_connectivity_results( lambda: _show_multi_connectivity_results(
"CF Worker", per, label_prefix='DC', t("connectivity.cfworker_title"), per, label_prefix='DC',
), ),
) )
except Exception as exc: except Exception as exc:
log.error("CF worker test failed: %s", exc) log.error("CF worker test failed: %s", exc)
finally: finally:
btn.after(0, lambda: btn.configure(text="Тест")) btn.after(0, lambda: btn.configure(text=t("button.test")))
btn.after(0, _sync_cfworker_test_button) btn.after(0, _sync_cfworker_test_button)
_threading.Thread(target=_worker, daemon=True).start() _threading.Thread(target=_worker, daemon=True).start()
@@ -665,7 +691,7 @@ def install_tray_config_form(
).pack(side="right") ).pack(side="right")
_cfworker_test_widget = ctk.CTkButton( _cfworker_test_widget = ctk.CTkButton(
cf_worker_input, text="Тест", width=56, height=32, cf_worker_input, text=t("button.test"), width=56, height=32,
font=(theme.ui_font_family, 13), corner_radius=8, font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border, text_color="#ffffff", border_width=1, border_color=theme.field_border,
@@ -676,20 +702,20 @@ def install_tray_config_form(
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button) cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
_sync_cfworker_test_button() _sync_cfworker_test_button()
log_inner = _config_section(ctk, frame, theme, "Логи и производительность") log_inner = _config_section(ctk, frame, theme, t("section.logs"))
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var) verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6)) verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE) attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x") adv_frame.pack(fill="x")
adv_rows = [ adv_rows = [
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB), (t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), (t("label.pool_size"), "pool_size", t("tip.pool")),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), (t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
] ]
for label_text, key, tip in adv_rows: for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent") col = ctk.CTkFrame(adv_frame, fg_color="transparent")
@@ -706,38 +732,32 @@ def install_tray_config_form(
adv_entries = list(adv_frame.winfo_children()) adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb") adv_keys = ("buf_kb", "pool_size", "log_max_mb")
upd_inner = _config_section(ctk, frame, theme, "Обновления") upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
st = get_status() st = get_status()
check_updates_var = ctk.BooleanVar( check_updates_var = ctk.BooleanVar(
value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
) )
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var) upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6)) upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
if st.get("error"): if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть." upd_status = t("updates.status_error")
elif not st.get("checked"): elif not st.get("checked"):
upd_status = "Статус появится после фоновой проверки при запуске." upd_status = t("updates.status_pending")
elif st.get("has_update") and st.get("latest"): elif st.get("has_update") and st.get("latest"):
upd_status = ( upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
f"На GitHub доступна версия {st['latest']} "
f"(у вас {__version__})."
)
elif st.get("ahead_of_release") and st.get("latest"): elif st.get("ahead_of_release") and st.get("latest"):
upd_status = ( upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
f"У вас {__version__} — новее последнего релиза на GitHub "
f"({st['latest']})."
)
else: else:
upd_status = "Установлена последняя известная версия с GitHub." upd_status = t("updates.status_latest")
_label(ctk, upd_inner, theme, upd_status, size=11, _label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton( ctk.CTkButton(
upd_inner, text="Открыть страницу релиза", height=32, upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), corner_radius=8, font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border, fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, text_color=theme.text_primary, border_width=1,
@@ -747,17 +767,17 @@ def install_tray_config_form(
autostart_var = None autostart_var = None
if show_autostart: if show_autostart:
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value) autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
as_cb.pack(anchor="w", pady=(0, 4)) as_cb.pack(anchor="w", pady=(0, 4))
as_hint = _label( as_hint = _label(
ctk, sys_inner, theme, ctk, sys_inner, theme,
"Если переместить программу в другую папку, запись автозапуска может сброситься.", t("label.autostart_hint"),
size=11, justify="left", wraplength=_INNER_W, size=11, justify="left", wraplength=_INNER_W,
) )
as_hint.pack(anchor="w") as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
return TrayConfigFormWidgets( return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var, host_var=host_var, port_var=port_var, secret_var=secret_var,
@@ -768,6 +788,7 @@ def install_tray_config_form(
cfproxy_user_domain_var=cfproxy_user_domain_var, cfproxy_user_domain_var=cfproxy_user_domain_var,
cfproxy_worker_domain_var=cfproxy_worker_domain_var, cfproxy_worker_domain_var=cfproxy_worker_domain_var,
appearance_var=appearance_var, appearance_var=appearance_var,
language_var=language_var,
) )
@@ -788,6 +809,16 @@ def merge_adv_from_form(
base[key] = default_config[key] base[key] = default_config[key]
def _dc_validation_message(error: ValueError) -> str:
exc_entry = getattr(error, "entry", None)
if exc_entry is None:
return str(error)
kind = getattr(error, "kind", "invalid")
if kind == "format":
return t("validation.dc_format", entry=exc_entry)
return t("validation.dc_invalid", entry=exc_entry)
def validate_config_form( def validate_config_form(
widgets: TrayConfigFormWidgets, widgets: TrayConfigFormWidgets,
default_config: dict, default_config: dict,
@@ -800,14 +831,14 @@ def validate_config_form(
try: try:
_sock.inet_aton(host_val) _sock.inet_aton(host_val)
except OSError: except OSError:
return "Некорректный IP-адрес." return t("validation.bad_host")
try: try:
port_val = int(widgets.port_var.get().strip()) port_val = int(widgets.port_var.get().strip())
if not (1 <= port_val <= 65535): if not (1 <= port_val <= 65535):
raise ValueError raise ValueError
except ValueError: except ValueError:
return "Порт должен быть числом 1-65535" return t("validation.bad_port")
lines = [ lines = [
line.strip() line.strip()
@@ -817,15 +848,15 @@ def validate_config_form(
try: try:
parse_dc_ip_list(lines) parse_dc_ip_list(lines)
except ValueError as e: except ValueError as e:
return str(e) return _dc_validation_message(e)
secret_val = widgets.secret_var.get().strip() secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32: if len(secret_val) != 32:
return "Secret должен содержать ровно 32 hex-символа (16 байт)." return t("validation.bad_secret_len")
try: try:
bytes.fromhex(secret_val) bytes.fromhex(secret_val)
except ValueError: except ValueError:
return "Secret должен состоять только из hex-символов (0-9, a-f)." return t("validation.bad_secret_hex")
new_cfg: Dict[str, Any] = { new_cfg: Dict[str, Any] = {
"host": host_val, "host": host_val,
@@ -851,7 +882,9 @@ def validate_config_form(
if widgets.cfproxy_worker_domain_var is not None: if widgets.cfproxy_worker_domain_var is not None:
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get()) new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
if widgets.appearance_var is not None: if widgets.appearance_var is not None:
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto") new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
return new_cfg return new_cfg
@@ -872,22 +905,22 @@ def install_tray_config_buttons(
btn_frame = ctk.CTkFrame(frame, fg_color="transparent") btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
btn_frame.pack(fill="x", pady=(0, 0)) btn_frame.pack(fill="x", pady=(0, 0))
save_btn = ctk.CTkButton( save_btn = ctk.CTkButton(
btn_frame, text="Сохранить", height=38, btn_frame, text=t("button.save"), height=38,
font=(theme.ui_font_family, 14, "bold"), corner_radius=10, font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", text_color="#ffffff",
command=on_save) command=on_save)
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8)) save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
attach_ctk_tooltip(save_btn, _TIP_SAVE) attach_ctk_tooltip(save_btn, t("tip.save"))
cancel_btn = ctk.CTkButton( cancel_btn = ctk.CTkButton(
btn_frame, text="Отмена", height=38, btn_frame, text=t("button.cancel"), height=38,
font=(theme.ui_font_family, 14), corner_radius=10, font=(theme.ui_font_family, 14), corner_radius=10,
fg_color=theme.field_bg, hover_color=theme.field_border, fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, text_color=theme.text_primary, border_width=1,
border_color=theme.field_border, border_color=theme.field_border,
command=on_cancel) command=on_cancel)
cancel_btn.pack(side="right", fill="x", expand=True) cancel_btn.pack(side="right", fill="x", expand=True)
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL) attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
def populate_first_run_window( def populate_first_run_window(
@@ -912,19 +945,19 @@ def populate_first_run_window(
width=4, height=32, corner_radius=2) width=4, height=32, corner_radius=2)
accent_bar.pack(side="left", padx=(0, 12)) accent_bar.pack(side="left", padx=(0, 12))
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее", ctk.CTkLabel(title_frame, text=t("first_run.title"),
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary).pack(side="left") text_color=theme.text_primary).pack(side="left")
sections = [ sections = [
("Как подключить Telegram Desktop:", True), (t("first_run.how_to"), True),
(" Автоматически:", True), (t("first_run.auto"), True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False), (t("first_run.auto_hint"), False),
(f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), (t("first_run.auto_link", url=tg_url), False),
("\n Вручную:", True), ("\n" + t("first_run.manual"), True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (t("first_run.manual_path"), False),
(f" MTProto → {link_host} : {port}", False), (t("first_run.manual_mtproto", host=link_host, port=port), False),
(f" Secret: dd{secret}", False), (t("first_run.manual_secret", secret=secret), False),
] ]
textbox = ctk.CTkTextbox( textbox = ctk.CTkTextbox(
@@ -956,13 +989,13 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12)) corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True) auto_var = ctk.BooleanVar(value=True)
_checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас", _checkbox(ctk, frame, theme, t("first_run.open_now"),
auto_var).pack(anchor="w", pady=(0, 16)) auto_var).pack(anchor="w", pady=(0, 16))
def on_ok(): def on_ok():
on_done(auto_var.get()) on_done(auto_var.get())
ctk.CTkButton(frame, text="Начать", width=180, height=42, ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
font=(theme.ui_font_family, 15, "bold"), corner_radius=10, font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", text_color="#ffffff",
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
import locale
import os
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
LocaleInput = Union[str, "LocaleEnum"]
class LocaleEnum(str, Enum):
russian = "ru"
english = "en"
@classmethod
def parse(cls, value: LocaleInput) -> LocaleEnum:
if isinstance(value, cls):
return value
try:
return cls(value)
except ValueError:
return _DEFAULT_LOCALE
_LOCALES_DIR = Path(__file__).resolve().parent
_DEFAULT_LOCALE = LocaleEnum.english
_translations: Dict[str, str] = {}
_current_lang: LocaleEnum = _DEFAULT_LOCALE
_config_value: LocaleEnum = _DEFAULT_LOCALE
_LANGUAGE_TO_LABEL: Dict[LocaleEnum, str] = {}
_LABEL_TO_LANGUAGE: Dict[str, LocaleEnum] = {}
def _locale_json_files() -> Tuple[str, ...]:
return tuple(
p.stem for p in sorted(_LOCALES_DIR.glob("*.json")) if p.stem != "manifest"
)
def supported_languages() -> Tuple[str, ...]:
"""Locale codes that have a JSON catalog on disk (e.g. ru, en)."""
return _locale_json_files()
def content_locales() -> Tuple[LocaleEnum, ...]:
return tuple(
LocaleEnum(stem)
for stem in _locale_json_files()
if stem in LocaleEnum._value2member_map_
)
def detect_system_language() -> LocaleEnum:
"""Pick the best locale from available catalogs, else Russian."""
available = content_locales()
if not available:
return _DEFAULT_LOCALE
for getter in (locale.getlocale, locale.getdefaultlocale):
try:
loc = getter()
if loc and loc[0]:
code = loc[0].split("_")[0].lower()
try:
candidate = LocaleEnum(code)
if candidate in available:
return candidate
except ValueError:
pass
except Exception:
pass
for env_key in ("LC_ALL", "LC_MESSAGES", "LANG"):
val = os.environ.get(env_key, "")
if val:
code = val.split(".")[0].split("_")[0].lower()
try:
candidate = LocaleEnum(code)
if candidate in available:
return candidate
except ValueError:
pass
return _DEFAULT_LOCALE
def resolve_language(config_value: LocaleInput) -> LocaleEnum:
loc = LocaleEnum.parse(config_value)
if loc.value in supported_languages():
return loc
return _DEFAULT_LOCALE
def _load_locale(lang: LocaleEnum) -> Dict[str, str]:
path = _LOCALES_DIR / f"{lang.value}.json"
with open(path, encoding="utf-8") as f:
return json.load(f)
def set_language(config_value: LocaleInput) -> LocaleEnum:
global _translations, _current_lang, _config_value
_config_value = LocaleEnum.parse(config_value)
_current_lang = resolve_language(_config_value)
_translations = _load_locale(_current_lang)
refresh_language_option_maps()
return _current_lang
def get_language() -> LocaleEnum:
return _current_lang
def get_config_language() -> LocaleEnum:
return _config_value
def t(key: str, **kwargs: Any) -> str:
text = _translations.get(key, key)
if kwargs:
try:
return text.format(**kwargs)
except (KeyError, IndexError, ValueError):
return text
return text
def language_option_labels() -> List[Tuple[LocaleEnum, str]]:
"""Config values and display labels for the language combobox."""
return [
(loc, t(f"language.{loc.value}"))
for loc in content_locales()
]
def language_label_for_config(value: LocaleInput) -> str:
loc = LocaleEnum.parse(value)
labels = language_option_labels()
for cfg_val, label in labels:
if cfg_val == loc:
return label
return labels[0][1] if labels else _DEFAULT_LOCALE.value
def refresh_language_option_maps() -> None:
global _LANGUAGE_TO_LABEL, _LABEL_TO_LANGUAGE
_LANGUAGE_TO_LABEL = dict(language_option_labels())
_LABEL_TO_LANGUAGE = {label: val for val, label in _LANGUAGE_TO_LABEL.items()}
def language_from_label(label: str) -> LocaleEnum:
return _LABEL_TO_LANGUAGE.get(label, _DEFAULT_LOCALE)
def label_from_language(value: LocaleInput) -> str:
loc = LocaleEnum.parse(value)
return _LANGUAGE_TO_LABEL.get(
loc,
_LANGUAGE_TO_LABEL.get(_DEFAULT_LOCALE, _DEFAULT_LOCALE.value),
)
set_language(detect_system_language())
+149
View File
@@ -0,0 +1,149 @@
{
"app.name": "TG WS Proxy",
"app.error_title": "TG WS Proxy — Error",
"app.settings_title": "TG WS Proxy — Settings",
"app.update_title": "TG WS Proxy — Update",
"language.ru": "Русский",
"language.en": "English",
"appearance.auto": "Auto",
"appearance.light": "Light",
"appearance.dark": "Dark",
"settings.title": "Settings",
"settings.language": "Language",
"settings.theme": "Theme",
"section.interface": "Interface",
"section.mtproto": "MTProto Connection",
"section.dc": "Telegram Data Centers (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Logs & Performance",
"section.updates": "Updates",
"section.windows_startup": "Windows Startup",
"label.host": "IP address",
"label.port": "Port",
"label.secret": "Secret",
"label.dc_hint": "One rule per line, format: number:IP",
"label.cf_enable": "Enable CF proxy",
"label.cf_custom_domain": "Custom domain",
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
"label.verbose": "Verbose logging",
"label.buf_kb": "Buffer, KB (default 256)",
"label.pool_size": "WebSocket session pool (default 4)",
"label.log_max_mb": "Max log size, MB (default 5)",
"label.check_updates": "Check for updates on startup",
"label.autostart": "Start on system boot",
"label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.",
"tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces",
"tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port",
"tip.secret": "Secret key for client authorization",
"tip.dc": "Mapping of Telegram data center (DC) number to server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf media does not work with CF proxy enabled, try removing the line 2:149.154.167.220",
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
"tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset",
"tip.check_updates": "Check for updates on startup",
"tip.cfproxy": "Use Cloudflare proxy for unreachable data centers",
"tip.cfproxy_domain": "Your own domains proxied through Cloudflare for WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains",
"tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection",
"tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them",
"tip.save": "Save settings",
"tip.cancel": "Close without saving changes",
"button.save": "Save",
"button.cancel": "Cancel",
"button.test": "Test",
"button.test_loading": "...",
"button.open_release": "Open release page",
"button.start": "Get started",
"button.update": "Update",
"button.page": "Page",
"button.close": "Close",
"validation.bad_host": "Invalid IP address.",
"validation.bad_port": "Port must be a number between 1 and 65535",
"validation.bad_secret_len": "Secret must be exactly 32 hex characters (16 bytes).",
"validation.bad_secret_hex": "Secret must contain only hex characters (0-9, a-f).",
"validation.dc_format": "Invalid DC:IP format: {entry}",
"validation.dc_invalid": "Invalid DC:IP entry: {entry}",
"connectivity.cfproxy_title": "CF Proxy",
"connectivity.cfworker_title": "CF Worker",
"connectivity.timeout": "timeout",
"connectivity.no_response": "no response",
"connectivity.available": "{title}: available",
"connectivity.unavailable": "{title}: unavailable",
"connectivity.all_ok": "{title}: all working",
"connectivity.partial": "{title}: partially working",
"connectivity.auto_ok": "✓ {title} works. {ok} of {total} servers reachable.",
"connectivity.all_ok_domain": "✓ All {total} servers reachable via {domain}.",
"connectivity.none_ok": "✗ No servers respond via {domain}.\n\nErrors:\n{errors}",
"connectivity.partial_detail": "Domain: {domain}\n\n✓ Working: {ok_list}\n\n✗ Unreachable:\n{fail_list}",
"connectivity.error_line": " {prefix}{dc}: {error}",
"connectivity.cf_auto_fail": "✗ None of the automatic CF domains respond.",
"connectivity.multi_all_ok": "✓ {domain}: all {total} servers reachable",
"connectivity.multi_fail": "✗ {domain}: unavailable",
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
"updates.status_error": "Could not reach GitHub. Check your network.",
"updates.status_pending": "Status will appear after the background check on startup.",
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
"updates.status_latest": "Latest known version from GitHub is installed.",
"first_run.title": "Proxy is running in the system tray",
"first_run.how_to": "How to connect Telegram Desktop:",
"first_run.auto": " Automatically:",
"first_run.auto_hint": " Right-click tray icon → «Open in Telegram»",
"first_run.auto_link": " Or copy the link, send it to yourself in TG and click it: {url}",
"first_run.manual": " Manually:",
"first_run.manual_path": " Settings → Advanced → Connection type → Proxy",
"first_run.manual_mtproto": " MTProto → {host} : {port}",
"first_run.manual_secret": " Secret: dd{secret}",
"first_run.open_now": "Open proxy in Telegram now",
"tray.open_telegram": "Open in Telegram ({host}:{port})",
"tray.copy_link": "Copy link",
"tray.restart": "Restart proxy",
"tray.settings": "Settings...",
"tray.logs": "Open logs",
"tray.exit": "Exit",
"dialog.restart_title": "Restart?",
"dialog.restart_body": "Settings saved.\n\nRestart the proxy now?",
"dialog.already_running": "Application is already running.",
"dialog.log_not_found": "Log file has not been created yet.",
"dialog.ctk_missing": "customtkinter is not installed.",
"dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
"dialog.copy_fail": "Failed to copy link:\n{error}",
"dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}",
"dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}",
"dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}",
"dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.",
"dialog.log_open_fail": "Failed to open log file:\n{error}",
"dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}",
"update.available": "New version available: {version}",
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
"update.downloading": "Downloading...",
"update.replacing": "Replacing file...",
"update.restarting": "Restarting...",
"update.error": "Error: {msg}",
"update.download_fail": "Download failed:\n{error}",
"update.rename_fail": "Failed to rename file:\n{error}",
"update.move_fail": "Failed to move file:\n{error}",
"error.dc_config": "DC → IP configuration error.",
"diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.",
"diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 1000050000 in settings, check firewall/antivirus, and restart.",
"diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.",
"ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once."
}
+149
View File
@@ -0,0 +1,149 @@
{
"app.name": "TG WS Proxy",
"app.error_title": "TG WS Proxy — Ошибка",
"app.settings_title": "TG WS Proxy — Настройки",
"app.update_title": "TG WS Proxy — обновление",
"language.ru": "Русский",
"language.en": "English",
"appearance.auto": "Авто",
"appearance.light": "Светлая",
"appearance.dark": "Тёмная",
"settings.title": "Настройки",
"settings.language": "Язык",
"settings.theme": "Тема",
"section.interface": "Интерфейс",
"section.mtproto": "Подключение MTProto",
"section.dc": "Датацентры Telegram (DC → IP)",
"section.cfproxy": "Cloudflare Proxy",
"section.cfworker": "Cloudflare Worker",
"section.logs": "Логи и производительность",
"section.updates": "Обновления",
"section.windows_startup": "Запуск Windows",
"label.host": "IP-адрес",
"label.port": "Порт",
"label.secret": "Secret",
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
"label.cf_enable": "Включить CF-прокси",
"label.cf_custom_domain": "Свой домен",
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
"label.verbose": "Подробное логирование (verbose)",
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
"label.check_updates": "Проверять обновления при запуске",
"label.autostart": "Автозапуск при включении компьютера",
"label.autostart_hint": "Если переместить программу в другую папку, запись автозапуска может сброситься.",
"tip.host": "Адрес, на котором прокси принимает подключения.\nОбычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы",
"tip.port": "Порт прокси. В Telegram Desktop в настройках прокси должен быть указан тот же порт",
"tip.secret": "Секретный ключ для авторизации клиентов",
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
"tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится",
"tip.check_updates": "При запуске проверять наличие обновлений",
"tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров",
"tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов",
"tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора",
"tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP",
"tip.save": "Сохранить настройки",
"tip.cancel": "Закрыть окно без сохранения изменений",
"button.save": "Сохранить",
"button.cancel": "Отмена",
"button.test": "Тест",
"button.test_loading": "...",
"button.open_release": "Открыть страницу релиза",
"button.start": "Начать",
"button.update": "Обновить",
"button.page": "Страница",
"button.close": "Закрыть",
"validation.bad_host": "Некорректный IP-адрес.",
"validation.bad_port": "Порт должен быть числом 1-65535",
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
"validation.dc_format": "Неверный формат DC:IP: {entry}",
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
"connectivity.cfproxy_title": "CF-прокси",
"connectivity.cfworker_title": "CF Worker",
"connectivity.timeout": "таймаут",
"connectivity.no_response": "нет ответа",
"connectivity.available": "{title}: доступен",
"connectivity.unavailable": "{title}: недоступен",
"connectivity.all_ok": "{title}: всё работает",
"connectivity.partial": "{title}: частично работает",
"connectivity.auto_ok": "✓ {title} работает. {ok} из {total} серверов доступны.",
"connectivity.all_ok_domain": "✓ Все {total} серверов доступны через {domain}.",
"connectivity.none_ok": "✗ Ни один сервер не отвечает через {domain}.\n\nОшибки:\n{errors}",
"connectivity.partial_detail": "Домен: {domain}\n\n✓ Работают: {ok_list}\n\n✗ Недоступны:\n{fail_list}",
"connectivity.error_line": " {prefix}{dc}: {error}",
"connectivity.cf_auto_fail": "✗ Ни один из автоматических CF-доменов не отвечает.",
"connectivity.multi_all_ok": "✓ {domain}: все {total} серверов доступны",
"connectivity.multi_fail": "✗ {domain}: недоступен",
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
"first_run.title": "Прокси запущен и работает в системном трее",
"first_run.how_to": "Как подключить Telegram Desktop:",
"first_run.auto": " Автоматически:",
"first_run.auto_hint": " ПКМ по иконке в трее → «Открыть в Telegram»",
"first_run.auto_link": " Или скопировать ссылку, отправить её себе в TG и нажать по ней: {url}",
"first_run.manual": " Вручную:",
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
"first_run.manual_mtproto": " MTProto → {host} : {port}",
"first_run.manual_secret": " Secret: dd{secret}",
"first_run.open_now": "Открыть прокси в Telegram сейчас",
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
"tray.copy_link": "Скопировать ссылку",
"tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...",
"tray.logs": "Открыть логи",
"tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?",
"dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?",
"dialog.already_running": "Приложение уже запущено.",
"dialog.log_not_found": "Файл логов ещё не создан.",
"dialog.ctk_missing": "customtkinter не установлен.",
"dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"dialog.copy_fail": "Не удалось скопировать ссылку:\n{error}",
"dialog.open_tg_fail": "Не удалось открыть Telegram автоматически.\n\n{detail}",
"dialog.open_tg_fail_clipboard": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"dialog.open_tg_fail_manual": "Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}",
"dialog.pyperclip_missing": "Установите пакет pyperclip для копирования в буфер обмена.",
"dialog.log_open_fail": "Не удалось открыть файл логов:\n{error}",
"dialog.autostart_fail": "Не удалось изменить автозапуск.\n\nПопробуйте запустить приложение от имени пользователя с правами на реестр.\n\nОшибка: {error}",
"update.available": "Доступна новая версия: {version}",
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
"update.downloading": "Скачивание...",
"update.replacing": "Замена файла...",
"update.restarting": "Перезапуск...",
"update.error": "Ошибка: {msg}",
"update.download_fail": "Не удалось скачать:\n{error}",
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
"update.move_fail": "Не удалось переместить файл:\n{error}",
"error.dc_config": "Ошибка конфигурации DC → IP.",
"diagnostics.port_busy": "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.",
"diagnostics.permission": "Не удалось запустить прокси:\nДоступ к адресу/порту запрещён (брандмауэр, антивирус или права доступа).\n\nИзмените порт на случайный в диапазоне 10000–50000 в настройках, проверьте брандмауэр/антивирус и перезапустите.",
"diagnostics.bad_address": "Не удалось запустить прокси:\nНекорректный или недоступный адрес для прослушивания.\n\nПроверьте решение по открывшейся в браузере ссылке.\nПроверьте host и порт в настройках прокси и перезапустите.",
"ipv6.warning": "На вашем компьютере включена поддержка подключения по IPv6.\n\nTelegram может пытаться подключаться через IPv6, что не поддерживается и может привести к ошибкам.\n\nЕсли прокси не работает или в логах присутствуют ошибки, связанные с попытками подключения по IPv6 - попробуйте отключить в настройках прокси Telegram попытку соединения по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 в системе.\n\nЭто предупреждение будет показано только один раз."
}
+4
View File
@@ -8,6 +8,8 @@ import sys
import os import os
from typing import Any, Dict from typing import Any, Dict
from ui.i18n import detect_system_language
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = { _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1443, "port": 1443,
"host": "127.0.0.1", "host": "127.0.0.1",
@@ -20,12 +22,14 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"cfproxy": True, "cfproxy": True,
"cfproxy_user_domain": [], "cfproxy_user_domain": [],
"cfproxy_worker_domain": [], "cfproxy_worker_domain": [],
"ws_keepalive_interval": 30,
} }
def default_tray_config() -> Dict[str, Any]: def default_tray_config() -> Dict[str, Any]:
cfg = dict(_TRAY_DEFAULTS_COMMON) cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex() cfg["secret"] = os.urandom(16).hex()
cfg["language"] = detect_system_language().value
if sys.platform == "win32": if sys.platform == "win32":
cfg["autostart"] = False cfg["autostart"] = False
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import errno
import webbrowser
from typing import Optional, Tuple, Callable
# Windows WinSock error codes (exc.winerror); errno may differ from POSIX.
_WSA_EACCES = 10013
_WSA_EFAULT = 10014
_WSA_EADDRINUSE = 10048
_WSA_EADDRNOTAVAIL = 10049
def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[Callable]]:
"""Map a listen-socket bind failure to a user-facing message.
Returns None when the exception is not a recognizable bind failure,
so callers can fall back to generic handling.
"""
from ui.i18n import t
if not isinstance(exc, OSError):
return None
err = exc.errno
winerror = getattr(exc, "winerror", None)
if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE:
return t("diagnostics.port_busy"), None
if err == errno.EACCES or winerror == _WSA_EACCES:
return t("diagnostics.permission"), None
if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL)
or err in (errno.EADDRNOTAVAIL, errno.EFAULT)):
return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103")
return None, None
+39
View File
@@ -0,0 +1,39 @@
"""Shared construction of the rotating log file handler.
Centralizes the rotation invariant so both the tray and the CLI log paths
behave identically and the file can never grow without bound (issue #885).
A ``RotatingFileHandler`` only rotates when ``backupCount >= 1``: CPython's
``doRollover`` skips the entire rotation block when ``backupCount == 0``, so
``maxBytes`` is silently ignored and the active file grows forever. We force
at least one backup here regardless of caller input.
"""
from __future__ import annotations
import logging.handlers
_MIN_BYTES = 32 * 1024
_MIN_BACKUPS = 1
def build_log_handler(
path: str,
log_max_mb: float = 5,
backups: int = 1,
) -> logging.handlers.RotatingFileHandler:
"""Create a RotatingFileHandler that actually rotates.
``backups`` is clamped to at least 1 so rotation is always active, and
``maxBytes`` keeps a small floor so a misconfigured tiny size can't cause
rotation on every line.
"""
max_bytes = max(_MIN_BYTES, int(log_max_mb * 1024 * 1024))
backup_count = max(_MIN_BACKUPS, int(backups))
return logging.handlers.RotatingFileHandler(
path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
+93 -36
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import logging.handlers
import os import os
import shutil
import socket as _socket import socket as _socket
import sys import sys
import threading import threading
@@ -17,13 +17,16 @@ import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.tg_ws_proxy import _run from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.diagnostics import diagnose_listen_error
from utils.logging_setup import build_log_handler
log = logging.getLogger("tg-ws-tray") log = logging.getLogger("tg-ws-tray")
APP_NAME = "TgWsProxy" APP_NAME = "TgWsProxy"
PORTABLE_DIR_NAME = "TgWsProxy_data"
def _app_dir() -> Path: def _standard_app_dir() -> Path:
if sys.platform == "win32": if sys.platform == "win32":
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
if sys.platform == "darwin": if sys.platform == "darwin":
@@ -31,6 +34,61 @@ def _app_dir() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
def _exe_dir() -> Optional[Path]:
try:
base = getattr(sys, "frozen", False) and sys.executable or sys.argv[0]
except Exception:
return None
if not base:
return None
p = Path(base).resolve()
return p.parent if p.is_file() else p
def _detect_portable() -> Optional[Path]:
exe_dir = _exe_dir()
if exe_dir is None:
return None
portable_dir = exe_dir / PORTABLE_DIR_NAME
if "--portable" in sys.argv:
try:
portable_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create portable dir %s: %s", portable_dir, repr(exc))
return None
if portable_dir.is_dir():
_migrate_into_portable(portable_dir)
return portable_dir
return None
def _migrate_into_portable(portable_dir: Path) -> None:
try:
if any(portable_dir.iterdir()):
return
except OSError:
return
std = _standard_app_dir()
if not std.exists():
return
try:
for src in std.iterdir():
if ".log" in src.name:
continue
dst = portable_dir / src.name
try:
if not src.is_dir():
shutil.copy2(src, dst)
except OSError as exc:
log.warning("Portable migration: skip %s: %s", src.name, repr(exc))
except OSError as exc:
log.warning("Portable migration failed: %s", repr(exc))
def _app_dir() -> Path:
return _detect_portable() or _standard_app_dir()
APP_DIR = _app_dir() APP_DIR = _app_dir()
CONFIG_FILE = APP_DIR / "config.json" CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log" LOG_FILE = APP_DIR / "proxy.log"
@@ -122,18 +180,28 @@ def release_lock() -> None:
# config # config
def _apply_ui_language(cfg: dict) -> None:
from ui.i18n import set_language
set_language(cfg.get("language", DEFAULT_CONFIG["language"]))
def load_config() -> dict: def load_config() -> dict:
ensure_dirs() ensure_dirs()
cfg: Optional[dict] = None
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
try: try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f: with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
for k, v in DEFAULT_CONFIG.items(): for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v) data.setdefault(k, v)
return data cfg = data
except Exception as exc: except Exception as exc:
log.warning("Failed to load config: %s", repr(exc)) log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG) if cfg is None:
cfg = dict(DEFAULT_CONFIG)
_apply_ui_language(cfg)
return cfg
def save_config(cfg: dict) -> None: def save_config(cfg: dict) -> None:
@@ -155,12 +223,7 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
root.setLevel(level) root.setLevel(level)
logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('asyncio').setLevel(logging.WARNING)
fh = logging.handlers.RotatingFileHandler( fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
str(LOG_FILE),
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh) root.addHandler(fh)
@@ -231,7 +294,7 @@ _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None _async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None: def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
global _async_stop global _async_stop
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@@ -243,13 +306,11 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
loop.run_until_complete(_run(stop_event=stop_ev)) loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc: except Exception as exc:
log.error("Proxy thread crashed: %s", repr(exc)) log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc): msg, diagnose_called = diagnose_listen_error(exc)
on_port_busy( if msg:
"Не удалось запустить прокси:\n" show_error(msg)
"Порт уже используется другим приложением.\n\n" if diagnose_called:
"Закройте приложение, использующее этот порт, " diagnose_called()
"или измените порт в настройках прокси и перезапустите."
)
finally: finally:
loop.close() loop.close()
_async_stop = None _async_stop = None
@@ -273,6 +334,7 @@ def apply_proxy_config(cfg: dict) -> bool:
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"]) pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])) pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"])) pc.cfproxy_worker_domains = coerce_domain_list(cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG["cfproxy_worker_domain"]))
pc.ws_keepalive_interval = max(0, cfg.get("ws_keepalive_interval", DEFAULT_CONFIG["ws_keepalive_interval"]))
return True return True
@@ -283,7 +345,8 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
return return
if not apply_proxy_config(cfg): if not apply_proxy_config(cfg):
on_error("Ошибка конфигурации DC → IP.") from ui.i18n import t
on_error(t("error.dc_config"))
return return
pc = proxy_config pc = proxy_config
@@ -301,6 +364,9 @@ def stop_proxy() -> None:
loop.call_soon_threadsafe(stop_ev.set) loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread: if _proxy_thread:
_proxy_thread.join(timeout=5) _proxy_thread.join(timeout=5)
if _proxy_thread.is_alive():
log.warning("Proxy thread did not stop within timeout; "
"port may still be in use")
_proxy_thread = None _proxy_thread = None
log.info("Proxy stopped") log.info("Proxy stopped")
@@ -308,7 +374,7 @@ def stop_proxy() -> None:
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
log.info("Restarting proxy...") log.info("Restarting proxy...")
stop_proxy() stop_proxy()
time.sleep(0.3) time.sleep(1.0)
start_proxy(cfg, on_error) start_proxy(cfg, on_error)
@@ -320,19 +386,6 @@ def tg_proxy_url(cfg: dict) -> str:
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
_IPV6_WARNING = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз."
)
def _has_ipv6() -> bool: def _has_ipv6() -> bool:
try: try:
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6): for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
@@ -355,8 +408,10 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
if IPV6_WARN_MARKER.exists() or not _has_ipv6(): if IPV6_WARN_MARKER.exists() or not _has_ipv6():
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
from ui.i18n import t
threading.Thread( threading.Thread(
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"), target=lambda: show_info(t("ipv6.warning"), t("app.name")),
daemon=True, daemon=True,
).start() ).start()
@@ -385,9 +440,11 @@ def maybe_notify_update(
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" ver = st.get("latest") or "?"
from ui.i18n import t
if ask_open( if ask_open(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", t("update.ask_open", version=ver),
"TG WS Proxy — обновление", t("app.update_title"),
): ):
webbrowser.open(url) webbrowser.open(url)
except Exception as exc: except Exception as exc:
+69 -21
View File
@@ -1,5 +1,5 @@
""" """
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей). Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304. данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
@@ -7,7 +7,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from itertools import zip_longest from itertools import zip_longest
@@ -19,6 +18,7 @@ from proxy.utils import build_github_opener
REPO = "Flowseal/tg-ws-proxy" REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag). # Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
@@ -37,13 +37,8 @@ _state: Dict[str, Any] = {
def _cache_file() -> Optional[Path]: def _cache_file() -> Optional[Path]:
try: try:
if sys.platform == "win32": from utils.tray_common import APP_DIR
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy" root = APP_DIR
elif sys.platform == "darwin":
root = Path.home() / "Library/Application Support/TgWsProxy"
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
root = (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "TgWsProxy"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root / ".update_check_cache.json" return root / ".update_check_cache.json"
except OSError: except OSError:
@@ -229,19 +224,60 @@ def run_check(current_version: str) -> None:
_state["html_url"] = RELEASES_PAGE_URL _state["html_url"] = RELEASES_PAGE_URL
def fetch_release_by_tag(
tag: str, timeout: float = 12.0,
) -> Tuple[Optional[dict], int]:
if not tag:
return None, 0
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
}
req = Request(
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
headers=headers,
method="GET",
)
try:
with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode()
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), int(code)
except HTTPError as e:
if e.code in [304, 404]:
return None, e.code
raise
def _extract_assets(data: Optional[dict]) -> list:
if not data:
return []
return [
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
for a in (data.get("assets") or [])
if a.get("name") and a.get("browser_download_url")
]
def get_status() -> Dict[str, Any]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" """Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state) return dict(_state)
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]: def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or [] new_assets = _state.get("assets") or []
if not assets: if not new_assets:
return None return None
# Try SHA256 match against release asset digests target_name = None
# SHA256 match
try: try:
import hashlib import hashlib
data, code = fetch_release_by_tag(f"v{current_version}")
if code == 200 and data:
cur_assets = _extract_assets(data)
if cur_assets:
h = hashlib.sha256() h = hashlib.sha256()
with open(exe_path, "rb") as f: with open(exe_path, "rb") as f:
while True: while True:
@@ -250,27 +286,39 @@ def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
break break
h.update(chunk) h.update(chunk)
exe_sha = h.hexdigest().lower() exe_sha = h.hexdigest().lower()
for a in assets: for a in cur_assets:
d = (a.get("digest") or "").lower() d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha: if d.startswith("sha256:") and d[7:] == exe_sha:
return a["url"], a["name"] target_name = a["name"]
break
except Exception: except Exception:
pass pass
# Fallback # Fallback
if not target_name or target_name not in [a.get("name") for a in new_assets]:
import platform
import struct import struct
is_64 = struct.calcsize("P") * 8 == 64 is_64 = struct.calcsize("P") * 8 == 64
machine = platform.machine().lower()
is_arm64 = machine in ("arm64", "aarch64")
try: try:
is_modern = sys.getwindowsversion().major >= 10 is_modern = sys.getwindowsversion().major >= 10
except Exception: except Exception:
is_modern = True is_modern = True
if is_modern:
name = "TgWsProxy_windows.exe" if is_arm64:
target_name = "TgWsProxy_windows_arm64.exe"
elif is_modern:
target_name = "TgWsProxy_windows.exe"
elif is_64: elif is_64:
name = "TgWsProxy_windows_7_64bit.exe" target_name = "TgWsProxy_windows_7_64bit.exe"
else: else:
name = "TgWsProxy_windows_7_32bit.exe" target_name = "TgWsProxy_windows_7_32bit.exe"
for a in assets:
if a.get("name") == name: for a in new_assets:
if a.get("name") == target_name:
return a["url"], a["name"] return a["url"], a["name"]
return None return None
+61 -50
View File
@@ -56,6 +56,7 @@ from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
) )
from ui.i18n import set_language, t
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
@@ -110,22 +111,23 @@ _IDYES = 6
_IDNO = 7 _IDNO = 7
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: def _show_error(text: str, title: Optional[str] = None) -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_ERR) _u32.MessageBoxW(None, text, title or t("app.error_title"), _MB_OK_ERR)
def _show_info(text: str, title: str = "TG WS Proxy") -> None: def _show_info(text: str, title: Optional[str] = None) -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_INFO) _u32.MessageBoxW(None, text, title or t("app.name"), _MB_OK_INFO)
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES return _u32.MessageBoxW(None, text, title or t("app.name"), _MB_YESNO_Q) == _IDYES
def update_ctk_form( def update_ctk_form(
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None, text: str, title: Optional[str] = None, download_url: Optional[str] = None,
release_url: Optional[str] = None, release_url: Optional[str] = None,
) -> str: ) -> str:
title = title or t("app.name")
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q) result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES: if result == _IDYES:
@@ -194,19 +196,19 @@ def update_ctk_form(
if IS_FROZEN: if IS_FROZEN:
btn_upd = ctk.CTkButton( btn_upd = ctk.CTkButton(
row, text="Обновить", width=88, height=34, row, text=t("button.update"), width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update, font=(theme.ui_font_family, 13), command=_on_update,
) )
btn_upd.pack(side="left", padx=(0, 6)) btn_upd.pack(side="left", padx=(0, 6))
btns.append(btn_upd) btns.append(btn_upd)
btn_pg = ctk.CTkButton( btn_pg = ctk.CTkButton(
row, text="Страница", width=88, height=34, row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"), font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
) )
btn_pg.pack(side="left", padx=(0, 6)) btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg) btns.append(btn_pg)
btn_cl = ctk.CTkButton( btn_cl = ctk.CTkButton(
row, text="Закрыть", width=88, height=34, row, text=t("button.close"), width=88, height=34,
font=(theme.ui_font_family, 13), font=(theme.ui_font_family, 13),
fg_color=theme.field_bg, hover_color=theme.field_border, fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1, border_color=theme.field_border, text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
@@ -231,11 +233,11 @@ def _perform_update(download_url: str, set_status=None) -> None:
def _err(msg: str) -> None: def _err(msg: str) -> None:
log.error("Update error: %s", msg) log.error("Update error: %s", msg)
if set_status: if set_status:
set_status(f"Ошибка: {msg}") set_status(f"{t('update.error', msg=msg)}")
else: else:
_show_error(msg) _show_error(msg)
_step("Скачивание...") _step(t("update.downloading"))
cur_exe = Path(sys.executable) cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe") old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
tmp_path = None tmp_path = None
@@ -253,7 +255,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
break break
_fout.write(_chunk) _fout.write(_chunk)
except Exception as exc: except Exception as exc:
_err(f"Не удалось скачать:\n{exc}") _err(t("update.download_fail", error=exc))
if tmp_path: if tmp_path:
try: try:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
@@ -261,13 +263,13 @@ def _perform_update(download_url: str, set_status=None) -> None:
pass pass
return return
_step("Замена файла...") _step(t("update.replacing"))
try: try:
if old_exe.exists(): if old_exe.exists():
old_exe.unlink() old_exe.unlink()
cur_exe.rename(old_exe) cur_exe.rename(old_exe)
except Exception as exc: except Exception as exc:
_err(f"Не удалось переименовать файл:\n{exc}") _err(t("update.rename_fail", error=exc))
try: try:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
except OSError: except OSError:
@@ -277,7 +279,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
try: try:
tmp_path.rename(cur_exe) tmp_path.rename(cur_exe)
except Exception as exc: except Exception as exc:
_err(f"Не удалось переместить файл:\n{exc}") _err(t("update.move_fail", error=exc))
try: try:
old_exe.rename(cur_exe) old_exe.rename(cur_exe)
except OSError: except OSError:
@@ -288,7 +290,7 @@ def _perform_update(download_url: str, set_status=None) -> None:
pass pass
return return
_step("Перезапуск...") _step(t("update.restarting"))
_release_win_mutex() _release_win_mutex()
stop_proxy() stop_proxy()
@@ -333,9 +335,9 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form( choice = update_ctk_form(
f"Доступна новая версия: {ver}", t("update.available", version=ver),
download_url=asset[0] if asset else None, download_url=asset[0] if asset else None,
release_url=url, release_url=url,
) )
@@ -382,9 +384,7 @@ def set_autostart_enabled(enabled: bool) -> None:
except OSError as exc: except OSError as exc:
log.error("Failed to update autostart: %s", exc) log.error("Failed to update autostart: %s", exc)
_show_error( _show_error(
"Не удалось изменить автозапуск.\n\n" t("dialog.autostart_fail", error=exc)
"Попробуйте запустить приложение от имени пользователя "
f"с правами на реестр.\n\nОшибка: {exc}"
) )
@@ -400,34 +400,30 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(
"Не удалось открыть Telegram автоматически.\n\n" t("dialog.open_tg_fail_manual", url=url)
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
) )
return return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
"Не удалось открыть Telegram автоматически.\n\n" t("dialog.open_tg_fail_clipboard", url=url)
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\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(t("dialog.copy_fail", error=exc))
def _on_copy_link(icon=None, item=None) -> None: def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config) url = tg_proxy_url(_config)
log.info("Copying link: %s", url) log.info("Copying link: %s", url)
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(t("dialog.pyperclip_missing"))
"Установите пакет pyperclip для копирования в буфер обмена."
)
return return
try: try:
pyperclip.copy(url) pyperclip.copy(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(t("dialog.copy_fail", error=exc))
def _on_restart(icon=None, item=None) -> None: def _on_restart(icon=None, item=None) -> None:
@@ -447,9 +443,9 @@ def _on_open_logs(icon=None, item=None) -> None:
os.startfile(str(LOG_FILE)) os.startfile(str(LOG_FILE))
except Exception as exc: except Exception as exc:
log.error("Failed to open log file: %s", exc) log.error("Failed to open log file: %s", exc)
_show_error(f"Не удалось открыть файл логов:\n{exc}") _show_error(t("dialog.log_open_fail", error=exc))
else: else:
_show_info("Файл логов ещё не создан.") _show_info(t("dialog.log_not_found"))
def _on_exit(icon=None, item=None) -> None: def _on_exit(icon=None, item=None) -> None:
@@ -469,7 +465,7 @@ def _on_exit(icon=None, item=None) -> None:
def _edit_config_dialog() -> None: def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.") _show_error(t("dialog.ctk_missing"))
return return
cfg = dict(_config) cfg = dict(_config)
@@ -484,45 +480,60 @@ def _edit_config_dialog() -> None:
h += 100 h += 100
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
def _refresh_tray_menu() -> None:
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
_original_language = _config.get("language", DEFAULT_CONFIG["language"])
widgets = install_tray_config_form( widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG, ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=_supports_autostart(), show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False), autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu,
) )
_original_appearance = ctk.get_appearance_mode() _original_appearance = ctk.get_appearance_mode()
def _restore_ui_locale() -> None:
set_language(_original_language)
_refresh_tray_menu()
def _finish() -> None: def _finish() -> None:
root.destroy() root.destroy()
done.set() done.set()
def _cancel() -> None: def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance) ctk.set_appearance_mode(_original_appearance)
_restore_ui_locale()
_finish() _finish()
def on_save() -> None: def on_save() -> None:
from tkinter import messagebox from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
if isinstance(merged, str): if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) messagebox.showerror(t("app.error_title"), merged, parent=root)
return return
_ui_only_keys = {"appearance", "autostart", "check_updates"} _ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged) config_changed = any(merged.get(k) != _config.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed: if not config_changed:
_restore_ui_locale()
_finish() _finish()
return return
save_config(merged) save_config(merged)
_config.update(merged) _config.update(merged)
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
log.info("Config saved: %s", merged) log.info("Config saved: %s", merged)
if _supports_autostart(): if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False))) set_autostart_enabled(bool(merged.get("autostart", False)))
@@ -533,8 +544,8 @@ def _edit_config_dialog() -> None:
return return
do_restart = messagebox.askyesno( do_restart = messagebox.askyesno(
"Перезапустить?", t("dialog.restart_title"),
"Настройки сохранены.\n\nПерезапустить прокси сейчас?", t("dialog.restart_body"),
parent=root, parent=root,
) )
_finish() _finish()
@@ -565,7 +576,7 @@ def _show_first_run() -> None:
theme = ctk_theme_for_platform() theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme, ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
@@ -590,14 +601,14 @@ def _build_menu():
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) link_host = get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem(t("tray.restart"), _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem(t("tray.settings"), _on_edit_config),
pystray.MenuItem("Открыть логи", _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit), pystray.MenuItem(t("tray.exit"), _on_exit),
) )
@@ -628,7 +639,7 @@ def run_tray() -> None:
_show_first_run() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@@ -638,7 +649,7 @@ def run_tray() -> None:
def main() -> None: def main() -> None:
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock(): if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
if IS_FROZEN: if IS_FROZEN: