mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-27 17:01:07 +03:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f9edf3072 | |||
| 9ff95d1222 | |||
| 4c19a6cce4 | |||
| bb900e0c9e | |||
| 1cdbca8893 | |||
| 0df9174ce2 | |||
| 7fc24fea95 | |||
| 5c40fa2574 | |||
| c8f6f8caf4 | |||
| d3d30799a1 | |||
| a0806d5a22 | |||
| eb22fadb7a | |||
| 43bca3a71b | |||
| 6b5fd72612 | |||
| 85b5e7f22a | |||
| fed772049b | |||
| 91d39a5ebe | |||
| 5cbac657dc | |||
| ee6c34e065 | |||
| ce6a456bd1 | |||
| 5bc5001c4d | |||
| 2afd80825b | |||
| 12fafbc8f4 | |||
| 5839ca2564 | |||
| e40c571009 | |||
| 96e5b4b639 | |||
| 13d2b1db6d | |||
| a29a1a8610 | |||
| 94010f1481 | |||
| 42172235c7 | |||
| b0010af130 | |||
| 784a7f659b | |||
| 21fe672963 | |||
| ed46ecce5a | |||
| 9562b11101 | |||
| dfdb993da5 | |||
| d4f8b51326 | |||
| ca431633d7 | |||
| ea4e8e790a | |||
| 05d6de269b | |||
| 1c4b103df2 | |||
| 23f0e4d426 | |||
| 49e62ca142 | |||
| 5915a0e1f3 | |||
| 7bc9e133c8 | |||
| 12d3d5e478 | |||
| b7cca232ea |
@@ -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
|
||||||
|
|||||||
+111
-40
@@ -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:
|
||||||
@@ -455,7 +525,7 @@ jobs:
|
|||||||
tag_name: ${{ github.event.inputs.version }}
|
tag_name: ${{ github.event.inputs.version }}
|
||||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||||
body: |
|
body: |
|
||||||
##
|
---
|
||||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -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
|
||||||
|
|||||||
+4
-3
@@ -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 []
|
||||||
@@ -47,8 +47,8 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
|||||||
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
||||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
||||||
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
||||||
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare. [Подробнее](./CfProxy.md) |
|
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md) |
|
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
||||||
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||||
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ cloudflare.dev
|
|||||||
workers.dev
|
workers.dev
|
||||||
```
|
```
|
||||||
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
|
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
|
||||||
|
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
|
||||||
3. Слева в панели выберите `Compute` → `Workers & Pages`
|
3. Слева в панели выберите `Compute` → `Workers & Pages`
|
||||||
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
|
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ 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` |
|
||||||
|
|
||||||
Пример с ручным указанием секрета:
|
Пример с ручным указанием секрета:
|
||||||
|
|
||||||
|
|||||||
+16
-5
@@ -1,3 +1,12 @@
|
|||||||
|
<div align="center">
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> ### [🎉 Поддержать меня](./Funding.md)
|
> ### [🎉 Поддержать меня](./Funding.md)
|
||||||
@@ -24,8 +33,8 @@
|
|||||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="https://github.com/user-attachments/assets/17f1d15e-e1c2-41ea-a452-220d13359262" media="(prefers-color-scheme: dark)">
|
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
|
||||||
<img src="https://github.com/user-attachments/assets/8d595468-83a1-4e4f-bac4-9ce4a07027bd">
|
<img src="./images/preview-white.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
## Навигация
|
## Навигация
|
||||||
@@ -40,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)
|
||||||
|
|
||||||
@@ -107,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+
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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()
|
||||||
|
|||||||
@@ -9,22 +9,59 @@ 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:
|
||||||
pyperclip = None
|
pyperclip = None
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
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.tray_common import (
|
from utils.tray_common import (
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -115,7 +153,7 @@ def _ask_cfworker_domain(default: str) -> Optional[str]:
|
|||||||
value = default
|
value = default
|
||||||
while True:
|
while True:
|
||||||
script = (
|
script = (
|
||||||
f'set d to display dialog "{_esc("Cloudflare Worker домен (например, name.account.workers.dev):")}" '
|
f'set d to display dialog "{_esc("Cloudflare Worker домены через запятую (например, name.account.workers.dev):")}" '
|
||||||
f'default answer "{_esc(value)}" '
|
f'default answer "{_esc(value)}" '
|
||||||
f'with title "TG WS Proxy" '
|
f'with title "TG WS Proxy" '
|
||||||
f'buttons {{"Закрыть", "?", "OK"}} '
|
f'buttons {{"Закрыть", "?", "OK"}} '
|
||||||
@@ -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
|
||||||
@@ -425,19 +443,24 @@ def _edit_config_dialog() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
cfproxy_domain = _osascript_input(
|
cfproxy_domain = _osascript_input(
|
||||||
"Свой CF-домен (оставьте пустым для автоматического выбора):\n"
|
"Свои CF-домены через запятую (оставьте пустым для автоматического выбора):\n"
|
||||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
||||||
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")),
|
", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", []))
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
if cfproxy_domain is None:
|
if cfproxy_domain is None:
|
||||||
return
|
return
|
||||||
cfproxy_domain = cfproxy_domain.strip()
|
cfproxy_domains = coerce_domain_list(cfproxy_domain)
|
||||||
|
|
||||||
cfworker_domain = _ask_cfworker_domain(
|
cfworker_domain = _ask_cfworker_domain(
|
||||||
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", ""))
|
", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", []))
|
||||||
|
))
|
||||||
)
|
)
|
||||||
if cfworker_domain is None:
|
if cfworker_domain is None:
|
||||||
return
|
return
|
||||||
|
cfworker_domains = coerce_domain_list(cfworker_domain)
|
||||||
|
|
||||||
new_cfg = {
|
new_cfg = {
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -450,8 +473,8 @@ def _edit_config_dialog() -> None:
|
|||||||
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
|
||||||
"check_updates": cfg.get("check_updates", True),
|
"check_updates": cfg.get("check_updates", True),
|
||||||
"cfproxy": cfproxy,
|
"cfproxy": cfproxy,
|
||||||
"cfproxy_user_domain": cfproxy_domain,
|
"cfproxy_user_domain": cfproxy_domains,
|
||||||
"cfproxy_worker_domain": cfworker_domain,
|
"cfproxy_worker_domain": cfworker_domains,
|
||||||
}
|
}
|
||||||
save_config(new_cfg)
|
save_config(new_cfg)
|
||||||
log.info("Config saved: %s", new_cfg)
|
log.info("Config saved: %s", new_cfg)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Executable
+93
@@ -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"
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,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, 0, 0),
|
filevers=(1, 8, 1, 0),
|
||||||
prodvers=(1, 7, 0, 0),
|
prodvers=(1, 8, 1, 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.0.0'),
|
StringStruct(u'FileVersion', u'1.8.1.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.0.0'),
|
StringStruct(u'ProductVersion', u'1.8.1.0'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
from .config import parse_dc_ip_list, proxy_config
|
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.0"
|
__version__ = "1.8.1"
|
||||||
|
|
||||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener"]
|
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
AES-CTR shim.
|
||||||
|
|
||||||
|
Prefers `cryptography` if available (desktop / Docker). Falls back to a
|
||||||
|
ctypes wrapper over the system OpenSSL `libcrypto` for environments where
|
||||||
|
installing `cryptography` is painful (Entware on routers, embedded boxes
|
||||||
|
without a Rust toolchain). The public surface mimics the small subset of
|
||||||
|
`cryptography.hazmat.primitives.ciphers` that this project actually uses:
|
||||||
|
Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor().update(data)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.ciphers import ( # noqa: F401
|
||||||
|
Cipher, algorithms, modes,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
|
||||||
|
def _load_libcrypto():
|
||||||
|
name = ctypes.util.find_library("crypto")
|
||||||
|
candidates = []
|
||||||
|
if name:
|
||||||
|
candidates.append(name)
|
||||||
|
candidates += [
|
||||||
|
"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so.1.0.0",
|
||||||
|
"libcrypto.so", "/opt/lib/libcrypto.so",
|
||||||
|
"/opt/lib/libcrypto.so.1.1", "/opt/lib/libcrypto.so.3",
|
||||||
|
]
|
||||||
|
last_err = None
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
return ctypes.CDLL(c)
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
raise RuntimeError(
|
||||||
|
"libcrypto not found; install openssl-util or "
|
||||||
|
"`opkg install libopenssl`. Last error: %r" % last_err
|
||||||
|
)
|
||||||
|
|
||||||
|
_libcrypto = _load_libcrypto()
|
||||||
|
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
|
||||||
|
_libcrypto.EVP_aes_128_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_192_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_aes_256_ctr.restype = ctypes.c_void_p
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
||||||
|
ctypes.c_char_p, ctypes.c_char_p,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptInit_ex.restype = ctypes.c_int
|
||||||
|
_libcrypto.EVP_EncryptUpdate.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
|
||||||
|
ctypes.c_char_p, ctypes.c_int,
|
||||||
|
]
|
||||||
|
_libcrypto.EVP_EncryptUpdate.restype = ctypes.c_int
|
||||||
|
|
||||||
|
_EVP_BY_KEY = {
|
||||||
|
16: _libcrypto.EVP_aes_128_ctr,
|
||||||
|
24: _libcrypto.EVP_aes_192_ctr,
|
||||||
|
32: _libcrypto.EVP_aes_256_ctr,
|
||||||
|
}
|
||||||
|
|
||||||
|
class algorithms:
|
||||||
|
class AES:
|
||||||
|
__slots__ = ("key",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
if len(key) not in _EVP_BY_KEY:
|
||||||
|
raise ValueError("AES key must be 16/24/32 bytes")
|
||||||
|
self.key = bytes(key)
|
||||||
|
|
||||||
|
class modes:
|
||||||
|
class CTR:
|
||||||
|
__slots__ = ("iv",)
|
||||||
|
|
||||||
|
def __init__(self, iv: bytes):
|
||||||
|
if len(iv) != 16:
|
||||||
|
raise ValueError("CTR IV must be 16 bytes")
|
||||||
|
self.iv = bytes(iv)
|
||||||
|
|
||||||
|
class _CtrStream:
|
||||||
|
__slots__ = ("_ctx",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes, iv: bytes):
|
||||||
|
ctx = _libcrypto.EVP_CIPHER_CTX_new()
|
||||||
|
if not ctx:
|
||||||
|
raise RuntimeError("EVP_CIPHER_CTX_new failed")
|
||||||
|
self._ctx = ctx
|
||||||
|
evp = _EVP_BY_KEY[len(key)]()
|
||||||
|
if _libcrypto.EVP_EncryptInit_ex(ctx, evp, None, key, iv) != 1:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
raise RuntimeError("EVP_EncryptInit_ex failed")
|
||||||
|
|
||||||
|
def update(self, data: bytes) -> bytes:
|
||||||
|
if not data:
|
||||||
|
return b""
|
||||||
|
outlen = ctypes.c_int(0)
|
||||||
|
buf = ctypes.create_string_buffer(len(data) + 16)
|
||||||
|
if _libcrypto.EVP_EncryptUpdate(
|
||||||
|
self._ctx, buf, ctypes.byref(outlen), bytes(data), len(data)
|
||||||
|
) != 1:
|
||||||
|
raise RuntimeError("EVP_EncryptUpdate failed")
|
||||||
|
return buf.raw[:outlen.value]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
ctx = getattr(self, "_ctx", None)
|
||||||
|
if ctx:
|
||||||
|
_libcrypto.EVP_CIPHER_CTX_free(ctx)
|
||||||
|
self._ctx = None
|
||||||
|
|
||||||
|
class Cipher:
|
||||||
|
__slots__ = ("_key", "_iv")
|
||||||
|
|
||||||
|
def __init__(self, algorithm, mode):
|
||||||
|
if not isinstance(algorithm, algorithms.AES):
|
||||||
|
raise TypeError("only AES is supported")
|
||||||
|
if not isinstance(mode, modes.CTR):
|
||||||
|
raise TypeError("only CTR mode is supported")
|
||||||
|
self._key = algorithm.key
|
||||||
|
self._iv = mode.iv
|
||||||
|
|
||||||
|
def encryptor(self) -> _CtrStream:
|
||||||
|
return _CtrStream(self._key, self._iv)
|
||||||
|
|
||||||
|
# CTR is symmetric — decryption == encryption with the same keystream.
|
||||||
|
decryptor = encryptor
|
||||||
+84
-63
@@ -1,9 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
import random
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from typing import List, Optional
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from .utils import *
|
from .utils import *
|
||||||
@@ -11,20 +11,14 @@ from .stats import stats
|
|||||||
from .balancer import balancer
|
from .balancer import balancer
|
||||||
from .config import proxy_config
|
from .config import proxy_config
|
||||||
from .raw_websocket import RawWebSocket
|
from .raw_websocket import RawWebSocket
|
||||||
|
from .pool import cf_worker_pool
|
||||||
|
from ._aes import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
_st_I_le = struct.Struct('<I')
|
_st_I_le = struct.Struct('<I')
|
||||||
|
|
||||||
ZERO_64 = b'\x00' * 64
|
ZERO_64 = b'\x00' * 64
|
||||||
DC_DEFAULT_IPS: Dict[int, str] = {
|
|
||||||
1: '149.154.175.50',
|
|
||||||
2: '149.154.167.51',
|
|
||||||
3: '149.154.175.100',
|
|
||||||
4: '149.154.167.91',
|
|
||||||
5: '149.154.171.5',
|
|
||||||
203: '91.105.192.100'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoCtx:
|
class CryptoCtx:
|
||||||
@@ -64,19 +58,27 @@ class MsgSplitter:
|
|||||||
self._plain_buf.extend(self._dec.update(chunk))
|
self._plain_buf.extend(self._dec.update(chunk))
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
while self._cipher_buf:
|
offset = 0
|
||||||
packet_len = self._next_packet_len()
|
buf_len = len(self._cipher_buf)
|
||||||
|
# Walk the buffer with an offset instead of deleting each packet from
|
||||||
|
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
||||||
|
# so a chunk holding many small packets degrades to O(N^2); a single
|
||||||
|
# trailing del keeps splitting O(N).
|
||||||
|
while offset < buf_len:
|
||||||
|
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||||
if packet_len is None:
|
if packet_len is None:
|
||||||
break
|
break
|
||||||
if packet_len <= 0:
|
if packet_len <= 0:
|
||||||
parts.append(bytes(self._cipher_buf))
|
parts.append(bytes(self._cipher_buf[offset:]))
|
||||||
self._cipher_buf.clear()
|
offset = buf_len
|
||||||
self._plain_buf.clear()
|
|
||||||
self._disabled = True
|
self._disabled = True
|
||||||
break
|
break
|
||||||
parts.append(bytes(self._cipher_buf[:packet_len]))
|
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||||
del self._cipher_buf[:packet_len]
|
offset += packet_len
|
||||||
del self._plain_buf[:packet_len]
|
|
||||||
|
if offset:
|
||||||
|
del self._cipher_buf[:offset]
|
||||||
|
del self._plain_buf[:offset]
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
def flush(self) -> List[bytes]:
|
def flush(self) -> List[bytes]:
|
||||||
@@ -87,22 +89,23 @@ class MsgSplitter:
|
|||||||
self._plain_buf.clear()
|
self._plain_buf.clear()
|
||||||
return [tail]
|
return [tail]
|
||||||
|
|
||||||
def _next_packet_len(self) -> Optional[int]:
|
def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
if not self._plain_buf:
|
if avail <= 0:
|
||||||
return None
|
return None
|
||||||
if self._proto == PROTO_ABRIDGED_INT:
|
if self._proto == PROTO_ABRIDGED_INT:
|
||||||
return self._next_abridged_len()
|
return self._next_abridged_len(offset, avail)
|
||||||
if self._proto in (PROTO_INTERMEDIATE_INT,
|
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||||
PROTO_PADDED_INTERMEDIATE_INT):
|
PROTO_PADDED_INTERMEDIATE_INT):
|
||||||
return self._next_intermediate_len()
|
return self._next_intermediate_len(offset, avail)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _next_abridged_len(self) -> Optional[int]:
|
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
first = self._plain_buf[0]
|
first = self._plain_buf[offset]
|
||||||
if first in (0x7F, 0xFF):
|
if first in (0x7F, 0xFF):
|
||||||
if len(self._plain_buf) < 4:
|
if avail < 4:
|
||||||
return None
|
return None
|
||||||
payload_len = int.from_bytes(self._plain_buf[1:4], 'little') * 4
|
payload_len = int.from_bytes(
|
||||||
|
self._plain_buf[offset + 1:offset + 4], 'little') * 4
|
||||||
header_len = 4
|
header_len = 4
|
||||||
else:
|
else:
|
||||||
payload_len = (first & 0x7F) * 4
|
payload_len = (first & 0x7F) * 4
|
||||||
@@ -110,33 +113,32 @@ class MsgSplitter:
|
|||||||
if payload_len <= 0:
|
if payload_len <= 0:
|
||||||
return 0
|
return 0
|
||||||
packet_len = header_len + payload_len
|
packet_len = header_len + payload_len
|
||||||
if len(self._plain_buf) < packet_len:
|
if avail < packet_len:
|
||||||
return None
|
return None
|
||||||
return packet_len
|
return packet_len
|
||||||
|
|
||||||
def _next_intermediate_len(self) -> Optional[int]:
|
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
|
||||||
if len(self._plain_buf) < 4:
|
if avail < 4:
|
||||||
return None
|
return None
|
||||||
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF
|
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
|
||||||
if payload_len <= 0:
|
if payload_len <= 0:
|
||||||
return 0
|
return 0
|
||||||
packet_len = 4 + payload_len
|
packet_len = 4 + payload_len
|
||||||
if len(self._plain_buf) < packet_len:
|
if avail < packet_len:
|
||||||
return None
|
return None
|
||||||
return packet_len
|
return packet_len
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def do_fallback(reader, writer, relay_init, label,
|
async def do_fallback(reader, writer, relay_init, label,
|
||||||
dc: int, is_media: bool, media_tag: str,
|
dc: int, is_media: bool, media_tag: str,
|
||||||
ctx: CryptoCtx, splitter=None):
|
ctx: CryptoCtx, splitter=None):
|
||||||
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
||||||
use_cf = proxy_config.fallback_cfproxy
|
use_cf = proxy_config.fallback_cfproxy
|
||||||
worker_domain = proxy_config.cfproxy_worker_domain
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
methods: List[str] = []
|
methods: List[str] = []
|
||||||
|
|
||||||
if worker_domain and fallback_dst:
|
if worker_domains and fallback_dst:
|
||||||
methods.append('cf_worker')
|
methods.append('cf_worker')
|
||||||
if use_cf:
|
if use_cf:
|
||||||
methods.append('cf')
|
methods.append('cf')
|
||||||
@@ -175,34 +177,42 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
|||||||
fallback_dst: str,
|
fallback_dst: str,
|
||||||
splitter=None):
|
splitter=None):
|
||||||
media_tag = ' media' if is_media else ''
|
media_tag = ' media' if is_media else ''
|
||||||
worker_domain = proxy_config.cfproxy_worker_domain
|
worker_domains = proxy_config.cfproxy_worker_domains
|
||||||
if not worker_domain:
|
if not worker_domains:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
random.shuffle(worker_domains)
|
||||||
|
|
||||||
query = urlencode({
|
for worker_domain in worker_domains:
|
||||||
'dst': fallback_dst,
|
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
|
||||||
'dc': str(dc),
|
if ws:
|
||||||
'media': '1' if is_media else '0',
|
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
|
||||||
})
|
label, dc, media_tag, fallback_dst)
|
||||||
path = f'/apiws?{query}'
|
else:
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
|
||||||
log.info("[%s] DC%d%s -> trying CF worker for %s",
|
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||||
label, dc, media_tag, fallback_dst)
|
label, dc, media_tag, worker_domain, fallback_dst)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||||
timeout=10.0, path=path)
|
timeout=10.0, path=path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("[%s] DC%d%s CF worker failed: %s",
|
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||||
label, dc, media_tag, repr(exc))
|
label, dc, media_tag, worker_domain, repr(exc))
|
||||||
return False
|
continue
|
||||||
|
|
||||||
stats.connections_cfproxy += 1
|
stats.connections_cfproxy += 1
|
||||||
await ws.send(relay_init)
|
await ws.send(relay_init)
|
||||||
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||||
dc=dc, is_media=is_media,
|
dc=dc, is_media=is_media,
|
||||||
splitter=splitter)
|
splitter=splitter)
|
||||||
return True
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||||
@@ -272,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)
|
||||||
@@ -300,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
|
||||||
@@ -320,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()),
|
||||||
@@ -338,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)
|
||||||
|
|||||||
+47
-7
@@ -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))
|
||||||
|
|
||||||
@@ -58,8 +68,8 @@ class ProxyConfig:
|
|||||||
buffer_size: int = 256 * 1024
|
buffer_size: int = 256 * 1024
|
||||||
pool_size: int = 4
|
pool_size: int = 4
|
||||||
fallback_cfproxy: bool = True
|
fallback_cfproxy: bool = True
|
||||||
cfproxy_user_domain: str = ''
|
cfproxy_user_domains: List[str] = field(default_factory=list)
|
||||||
cfproxy_worker_domain: str = ''
|
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||||
fake_tls_domain: str = ''
|
fake_tls_domain: str = ''
|
||||||
proxy_protocol: bool = False
|
proxy_protocol: bool = False
|
||||||
|
|
||||||
@@ -67,6 +77,30 @@ class ProxyConfig:
|
|||||||
proxy_config = ProxyConfig()
|
proxy_config = ProxyConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_domain_list(value) -> List[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
items = value.replace(',', ' ').replace(';', ' ').split()
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
items: List[str] = []
|
||||||
|
for entry in value:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
items.extend(entry.replace(',', ' ').replace(';', ' ').split())
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
seen = set()
|
||||||
|
result: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
key = item.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cfproxy_domain_list() -> List[str]:
|
def _fetch_cfproxy_domain_list() -> List[str]:
|
||||||
try:
|
try:
|
||||||
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||||
@@ -120,7 +154,7 @@ def _normalize_domain_pool(domains: List[str]) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
def refresh_cfproxy_domains() -> None:
|
def refresh_cfproxy_domains() -> None:
|
||||||
if proxy_config.cfproxy_user_domain:
|
if proxy_config.cfproxy_user_domains:
|
||||||
return
|
return
|
||||||
|
|
||||||
fetched = _fetch_cfproxy_domain_list()
|
fetched = _fetch_cfproxy_domain_list()
|
||||||
@@ -166,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
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from typing import Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
|
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||||
|
from .stats import stats
|
||||||
|
from .config import proxy_config
|
||||||
|
from .utils import ws_domains, DC_DEFAULT_IPS
|
||||||
|
|
||||||
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
|
class _WsPool:
|
||||||
|
WS_POOL_MAX_AGE = 120.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle: Dict[Tuple[int, bool], deque] = {}
|
||||||
|
self._refilling: Set[Tuple[int, bool]] = set()
|
||||||
|
self.fronting_until: float = 0.0
|
||||||
|
|
||||||
|
async def get(self, dc: int, is_media: bool,
|
||||||
|
target_ip: str, domains: List[str]
|
||||||
|
) -> Optional[RawWebSocket]:
|
||||||
|
key = (dc, is_media)
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[key] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.pool_hits += 1
|
||||||
|
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
||||||
|
dc, 'm' if is_media else '', age, len(bucket))
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
stats.pool_misses += 1
|
||||||
|
self._schedule_refill(key, target_ip, domains)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_refill(self, key, target_ip, domains):
|
||||||
|
if key in self._refilling:
|
||||||
|
return
|
||||||
|
self._refilling.add(key)
|
||||||
|
asyncio.create_task(self._refill(key, target_ip, domains))
|
||||||
|
|
||||||
|
async def _refill(self, key, target_ip, domains):
|
||||||
|
dc, is_media = key
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(key, deque())
|
||||||
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(
|
||||||
|
self._connect_one(target_ip, domains, time.monotonic() < self.fronting_until))
|
||||||
|
for _ in range(needed)]
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
ws = await t
|
||||||
|
if ws:
|
||||||
|
bucket.append((ws, time.monotonic()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log.debug("WS pool refilled DC%d%s: %d ready",
|
||||||
|
dc, 'm' if is_media else '', len(bucket))
|
||||||
|
finally:
|
||||||
|
self._refilling.discard(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _connect_one(target_ip, domains, fronting_active) -> Optional[RawWebSocket]:
|
||||||
|
for domain in domains:
|
||||||
|
try:
|
||||||
|
return await RawWebSocket.connect(
|
||||||
|
target_ip, domain, timeout=8, sni="sprinthost.ru" if fronting_active else None)
|
||||||
|
except WsHandshakeError as exc:
|
||||||
|
if exc.is_redirect:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _quiet_close(ws):
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
for dc, target_ip in proxy_config.dc_redirects.items():
|
||||||
|
if target_ip is None:
|
||||||
|
continue
|
||||||
|
for is_media in (False, True):
|
||||||
|
domains = ws_domains(dc, is_media)
|
||||||
|
self._schedule_refill((dc, is_media), target_ip, domains)
|
||||||
|
log.info("WS pool warmup started for %d DC(s)", len(proxy_config.dc_redirects))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._idle.clear()
|
||||||
|
self._refilling.clear()
|
||||||
|
self.fronting_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class _CfWorkerPool:
|
||||||
|
WS_POOL_MAX_AGE = 100.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle: Dict[Tuple[int, str], deque] = {}
|
||||||
|
self._refilling: Set[Tuple[int, str]] = set()
|
||||||
|
|
||||||
|
async def get(self, dc: int, worker_domain: str, fallback_dst: str) -> Optional[RawWebSocket]:
|
||||||
|
now = time.monotonic()
|
||||||
|
key = (dc, worker_domain)
|
||||||
|
|
||||||
|
bucket = self._idle.get(key)
|
||||||
|
if bucket is None:
|
||||||
|
bucket = deque()
|
||||||
|
self._idle[key] = bucket
|
||||||
|
while bucket:
|
||||||
|
ws, created = bucket.popleft()
|
||||||
|
age = now - created
|
||||||
|
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
||||||
|
or ws.writer.transport.is_closing()):
|
||||||
|
asyncio.create_task(self._quiet_close(ws))
|
||||||
|
continue
|
||||||
|
stats.cf_pool_hits += 1
|
||||||
|
log.debug("CF worker pool hit DC%d (age=%.1fs, left=%d)",
|
||||||
|
dc, age, len(bucket))
|
||||||
|
self._schedule_refill(key, fallback_dst)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
stats.cf_pool_misses += 1
|
||||||
|
self._schedule_refill(key, fallback_dst)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_refill(self, key, fallback_dst):
|
||||||
|
if key in self._refilling:
|
||||||
|
return
|
||||||
|
self._refilling.add(key)
|
||||||
|
asyncio.create_task(self._refill(key, fallback_dst))
|
||||||
|
|
||||||
|
async def _refill(self, key, fallback_dst):
|
||||||
|
dc, worker_domain = key
|
||||||
|
try:
|
||||||
|
bucket = self._idle.setdefault(key, deque())
|
||||||
|
needed = proxy_config.pool_size - len(bucket)
|
||||||
|
if needed <= 0:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(
|
||||||
|
self._connect_one(worker_domain, fallback_dst, dc))
|
||||||
|
for _ in range(needed)]
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
ws = await t
|
||||||
|
if ws:
|
||||||
|
bucket.append((ws, time.monotonic()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||||
|
dc, len(bucket))
|
||||||
|
finally:
|
||||||
|
self._refilling.discard(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _connect_one(worker_domain, fallback_dst, dc) -> Optional[RawWebSocket]:
|
||||||
|
query = urlencode({
|
||||||
|
'dst': fallback_dst,
|
||||||
|
'dc': str(dc),
|
||||||
|
})
|
||||||
|
path = f'/apiws?{query}'
|
||||||
|
try:
|
||||||
|
return await RawWebSocket.connect(
|
||||||
|
worker_domain, worker_domain, timeout=8, path=path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _quiet_close(ws):
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
cf_fallbacks = {
|
||||||
|
dc: ip for dc, ip in DC_DEFAULT_IPS.items()
|
||||||
|
if dc not in proxy_config.dc_redirects
|
||||||
|
}
|
||||||
|
|
||||||
|
if not cf_fallbacks or not proxy_config.cfproxy_worker_domains:
|
||||||
|
return
|
||||||
|
|
||||||
|
for worker_domain in proxy_config.cfproxy_worker_domains:
|
||||||
|
for dc, fallback_dst in cf_fallbacks.items():
|
||||||
|
self._schedule_refill((dc, worker_domain), fallback_dst)
|
||||||
|
|
||||||
|
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._idle.clear()
|
||||||
|
self._refilling.clear()
|
||||||
|
|
||||||
|
|
||||||
|
ws_pool = _WsPool()
|
||||||
|
cf_worker_pool = _CfWorkerPool()
|
||||||
+32
-2
@@ -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')
|
||||||
@@ -79,10 +82,14 @@ class RawWebSocket:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def connect(host: str, domain: str, timeout: float = 10.0,
|
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||||
path: str = '/apiws') -> 'RawWebSocket':
|
path: str = '/apiws', *,
|
||||||
|
sni: Optional[str] = None) -> 'RawWebSocket':
|
||||||
|
if sni is None:
|
||||||
|
sni = domain
|
||||||
|
|
||||||
reader, writer = await asyncio.wait_for(
|
reader, writer = await asyncio.wait_for(
|
||||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||||
server_hostname=domain),
|
server_hostname=sni),
|
||||||
timeout=min(timeout, 10))
|
timeout=min(timeout, 10))
|
||||||
|
|
||||||
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||||
@@ -99,6 +106,7 @@ class RawWebSocket:
|
|||||||
f'Sec-WebSocket-Protocol: binary\r\n'
|
f'Sec-WebSocket-Protocol: binary\r\n'
|
||||||
f'\r\n'
|
f'\r\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
writer.write(req.encode())
|
writer.write(req.encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|
||||||
@@ -160,6 +168,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 +213,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:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class _Stats:
|
|||||||
self.connections_ws = 0
|
self.connections_ws = 0
|
||||||
self.connections_tcp_fallback = 0
|
self.connections_tcp_fallback = 0
|
||||||
self.connections_cfproxy = 0
|
self.connections_cfproxy = 0
|
||||||
|
self.connections_fronting = 0
|
||||||
self.connections_bad = 0
|
self.connections_bad = 0
|
||||||
self.connections_masked = 0
|
self.connections_masked = 0
|
||||||
self.ws_errors = 0
|
self.ws_errors = 0
|
||||||
@@ -14,20 +15,27 @@ class _Stats:
|
|||||||
self.bytes_down = 0
|
self.bytes_down = 0
|
||||||
self.pool_hits = 0
|
self.pool_hits = 0
|
||||||
self.pool_misses = 0
|
self.pool_misses = 0
|
||||||
|
self.cf_pool_hits = 0
|
||||||
|
self.cf_pool_misses = 0
|
||||||
|
|
||||||
def summary(self) -> str:
|
def summary(self) -> str:
|
||||||
pool_total = self.pool_hits + self.pool_misses
|
pool_total = self.pool_hits + self.pool_misses
|
||||||
pool_s = (f"{self.pool_hits}/{pool_total}"
|
pool_s = (f"{self.pool_hits}/{pool_total}"
|
||||||
if pool_total else "n/a")
|
if pool_total else "n/a")
|
||||||
|
cf_pool_total = self.cf_pool_hits + self.cf_pool_misses
|
||||||
|
cf_pool_s = (f"{self.cf_pool_hits}/{cf_pool_total}"
|
||||||
|
if cf_pool_total else "n/a")
|
||||||
return (f"total={self.connections_total} "
|
return (f"total={self.connections_total} "
|
||||||
f"active={self.connections_active} "
|
f"active={self.connections_active} "
|
||||||
f"ws={self.connections_ws} "
|
f"ws={self.connections_ws} "
|
||||||
f"tcp_fb={self.connections_tcp_fallback} "
|
f"tcp_fb={self.connections_tcp_fallback} "
|
||||||
f"cf={self.connections_cfproxy} "
|
f"cf={self.connections_cfproxy} "
|
||||||
|
f"front={self.connections_fronting} "
|
||||||
f"bad={self.connections_bad} "
|
f"bad={self.connections_bad} "
|
||||||
f"masked={self.connections_masked} "
|
f"masked={self.connections_masked} "
|
||||||
f"err={self.ws_errors} "
|
f"err={self.ws_errors} "
|
||||||
f"pool={pool_s} "
|
f"pool={pool_s} "
|
||||||
|
f"cf_pool={cf_pool_s} "
|
||||||
f"up={human_bytes(self.bytes_up)} "
|
f"up={human_bytes(self.bytes_up)} "
|
||||||
f"down={human_bytes(self.bytes_down)}")
|
f"down={human_bytes(self.bytes_down)}")
|
||||||
|
|
||||||
|
|||||||
+177
-163
@@ -11,10 +11,8 @@ import logging
|
|||||||
import logging.handlers
|
import logging.handlers
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
|
|
||||||
from collections import deque
|
from typing import Dict, Optional, Set, Tuple
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
|
|
||||||
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
||||||
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -24,19 +22,27 @@ if __name__ == '__main__' and (__package__ is None or __package__ == ''):
|
|||||||
|
|
||||||
from .utils import *
|
from .utils import *
|
||||||
from .stats import stats
|
from .stats import stats
|
||||||
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh
|
from .config import proxy_config, parse_dc_ip_list, start_cfproxy_domain_refresh, coerce_domain_list
|
||||||
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
|
from .bridge import MsgSplitter, CryptoCtx, do_fallback, bridge_ws_reencrypt
|
||||||
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
|
from .raw_websocket import RawWebSocket, WsHandshakeError, set_sock_opts
|
||||||
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
|
from .fake_tls import proxy_to_masking_domain, verify_client_hello, build_server_hello, FakeTlsStream, TLS_RECORD_HANDSHAKE
|
||||||
from .balancer import balancer
|
from .balancer import balancer
|
||||||
|
from .pool import ws_pool, cf_worker_pool
|
||||||
|
from ._aes import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger('tg-mtproto-proxy')
|
log = logging.getLogger('tg-mtproto-proxy')
|
||||||
|
|
||||||
DC_FAIL_COOLDOWN = 30.0
|
IP_FAIL_COOLDOWN = 3600.0
|
||||||
|
DC_FAIL_COOLDOWN = 60.0
|
||||||
WS_FAIL_TIMEOUT = 2.0
|
WS_FAIL_TIMEOUT = 2.0
|
||||||
|
FRONTING_COOLDOWN = 1800.0
|
||||||
|
LISTENER_CHECK_INTERVAL = 5.0
|
||||||
|
LISTENER_RESTART_DELAY = 1.0
|
||||||
ws_blacklist: Set[str] = set()
|
ws_blacklist: Set[str] = set()
|
||||||
dc_fail_until: Dict[str, float] = {}
|
dc_fail_until: Dict[str, float] = {}
|
||||||
|
ip_fail_until: Dict[str, float] = {}
|
||||||
|
fronting_until: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, bytes, bytes]]:
|
||||||
@@ -100,112 +106,8 @@ def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes:
|
|||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
def _ws_domains(dc: int, is_media) -> List[str]:
|
|
||||||
if dc == 203:
|
|
||||||
dc = 2
|
|
||||||
if is_media is None or is_media:
|
|
||||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
|
||||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
|
||||||
|
|
||||||
|
|
||||||
class _WsPool:
|
|
||||||
WS_POOL_MAX_AGE = 120.0
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._idle: Dict[Tuple[int, bool], deque] = {}
|
|
||||||
self._refilling: Set[Tuple[int, bool]] = set()
|
|
||||||
|
|
||||||
async def get(self, dc: int, is_media: bool,
|
|
||||||
target_ip: str, domains: List[str]
|
|
||||||
) -> Optional[RawWebSocket]:
|
|
||||||
key = (dc, is_media)
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
bucket = self._idle.get(key)
|
|
||||||
if bucket is None:
|
|
||||||
bucket = deque()
|
|
||||||
self._idle[key] = bucket
|
|
||||||
while bucket:
|
|
||||||
ws, created = bucket.popleft()
|
|
||||||
age = now - created
|
|
||||||
if (age > self.WS_POOL_MAX_AGE or ws._closed
|
|
||||||
or ws.writer.transport.is_closing()):
|
|
||||||
asyncio.create_task(self._quiet_close(ws))
|
|
||||||
continue
|
|
||||||
stats.pool_hits += 1
|
|
||||||
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
|
|
||||||
dc, 'm' if is_media else '', age, len(bucket))
|
|
||||||
self._schedule_refill(key, target_ip, domains)
|
|
||||||
return ws
|
|
||||||
|
|
||||||
stats.pool_misses += 1
|
|
||||||
self._schedule_refill(key, target_ip, domains)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _schedule_refill(self, key, target_ip, domains):
|
|
||||||
if key in self._refilling:
|
|
||||||
return
|
|
||||||
self._refilling.add(key)
|
|
||||||
asyncio.create_task(self._refill(key, target_ip, domains))
|
|
||||||
|
|
||||||
async def _refill(self, key, target_ip, domains):
|
|
||||||
dc, is_media = key
|
|
||||||
try:
|
|
||||||
bucket = self._idle.setdefault(key, deque())
|
|
||||||
needed = proxy_config.pool_size - len(bucket)
|
|
||||||
if needed <= 0:
|
|
||||||
return
|
|
||||||
tasks = [asyncio.create_task(
|
|
||||||
self._connect_one(target_ip, domains))
|
|
||||||
for _ in range(needed)]
|
|
||||||
for t in tasks:
|
|
||||||
try:
|
|
||||||
ws = await t
|
|
||||||
if ws:
|
|
||||||
bucket.append((ws, time.monotonic()))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log.debug("WS pool refilled DC%d%s: %d ready",
|
|
||||||
dc, 'm' if is_media else '', len(bucket))
|
|
||||||
finally:
|
|
||||||
self._refilling.discard(key)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]:
|
|
||||||
for domain in domains:
|
|
||||||
try:
|
|
||||||
return await RawWebSocket.connect(
|
|
||||||
target_ip, domain, timeout=8)
|
|
||||||
except WsHandshakeError as exc:
|
|
||||||
if exc.is_redirect:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _quiet_close(ws):
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def warmup(self, dc_redirects: Dict[int, str]):
|
|
||||||
for dc, target_ip in dc_redirects.items():
|
|
||||||
if target_ip is None:
|
|
||||||
continue
|
|
||||||
for is_media in (False, True):
|
|
||||||
domains = _ws_domains(dc, is_media)
|
|
||||||
self._schedule_refill((dc, is_media), target_ip, domains)
|
|
||||||
log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self._idle.clear()
|
|
||||||
self._refilling.clear()
|
|
||||||
|
|
||||||
_ws_pool = _WsPool()
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_client_init(reader, writer, secret, label, masking):
|
async def _read_client_init(reader, writer, secret, label, masking):
|
||||||
if proxy_config.proxy_protocol:
|
if proxy_config.proxy_protocol:
|
||||||
@@ -348,6 +250,8 @@ def _build_crypto_ctx(client_dec_prekey_iv, secret, relay_init):
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_client(reader, writer, secret: bytes):
|
async def _handle_client(reader, writer, secret: bytes):
|
||||||
|
global fronting_until
|
||||||
|
|
||||||
stats.connections_total += 1
|
stats.connections_total += 1
|
||||||
stats.connections_active += 1
|
stats.connections_active += 1
|
||||||
peer = writer.get_extra_info('peername')
|
peer = writer.get_extra_info('peername')
|
||||||
@@ -393,15 +297,24 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
|
|
||||||
dc_key = f'{dc}{"m" if is_media else ""}'
|
dc_key = f'{dc}{"m" if is_media else ""}'
|
||||||
media_tag = " media" if is_media else ""
|
media_tag = " media" if is_media else ""
|
||||||
|
now = time.monotonic()
|
||||||
|
target = proxy_config.dc_redirects.get(dc)
|
||||||
|
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
|
||||||
|
|
||||||
|
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
|
||||||
|
if (dc not in proxy_config.dc_redirects
|
||||||
|
or dc_key in ws_blacklist
|
||||||
|
or now < ip_fail_until.get(target, 0) and is_any_cf_fallback):
|
||||||
|
|
||||||
# Fallback if DC not in config or WS blacklisted for this DC/is_media
|
|
||||||
if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
|
|
||||||
if dc not in proxy_config.dc_redirects:
|
if dc not in proxy_config.dc_redirects:
|
||||||
log.info("[%s] DC%d not in config -> fallback",
|
log.info("[%s] DC%d not in config -> fallback",
|
||||||
label, dc)
|
label, dc)
|
||||||
else:
|
elif dc_key in ws_blacklist:
|
||||||
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
||||||
label, dc, media_tag)
|
label, dc, media_tag)
|
||||||
|
else:
|
||||||
|
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
|
||||||
|
label, dc, media_tag, target)
|
||||||
splitter = None
|
splitter = None
|
||||||
try:
|
try:
|
||||||
splitter = MsgSplitter(relay_init, proto_int)
|
splitter = MsgSplitter(relay_init, proto_int)
|
||||||
@@ -416,20 +329,38 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
label, dc, media_tag)
|
label, dc, media_tag)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.monotonic()
|
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
|
||||||
fail_until = dc_fail_until.get(dc_key, 0)
|
fronting_active = now < fronting_until
|
||||||
ws_timeout = WS_FAIL_TIMEOUT if now < fail_until else 10.0
|
|
||||||
|
|
||||||
domains = _ws_domains(dc, is_media)
|
domains = ws_domains(dc, is_media)
|
||||||
target = proxy_config.dc_redirects[dc]
|
|
||||||
ws = None
|
ws = None
|
||||||
ws_failed_redirect = False
|
ws_failed_redirect = False
|
||||||
|
ws_timed_out = False
|
||||||
all_redirects = True
|
all_redirects = True
|
||||||
|
|
||||||
ws = await _ws_pool.get(dc, is_media, target, domains)
|
ws = await ws_pool.get(dc, is_media, target, domains)
|
||||||
if ws:
|
if ws:
|
||||||
log.info("[%s] DC%d%s -> pool hit via %s",
|
log.info("[%s] DC%d%s -> pool hit via %s",
|
||||||
label, dc, media_tag, target)
|
label, dc, media_tag, target)
|
||||||
|
elif fronting_active:
|
||||||
|
# TODO: Move fronting logic into bridge.py where other fallbacks are handled
|
||||||
|
log.info("[%s] DC%d%s -> fronting / Host %s",
|
||||||
|
label, dc, media_tag, domains[0])
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(target, domains[0],
|
||||||
|
timeout=5.0,
|
||||||
|
sni="sprinthost.ru")
|
||||||
|
except Exception as exc:
|
||||||
|
stats.ws_errors += 1
|
||||||
|
log.warning("[%s] DC%d%s fronting failed: %s",
|
||||||
|
label, dc, media_tag, repr(exc))
|
||||||
|
if ws:
|
||||||
|
stats.connections_fronting += 1
|
||||||
|
fronting_until = now + FRONTING_COOLDOWN
|
||||||
|
ws_pool.fronting_until = fronting_until
|
||||||
|
else:
|
||||||
|
fronting_until = 0.0
|
||||||
|
ws_pool.fronting_until = 0.0
|
||||||
else:
|
else:
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
url = f'wss://{domain}/apiws'
|
url = f'wss://{domain}/apiws'
|
||||||
@@ -453,14 +384,44 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
all_redirects = False
|
all_redirects = False
|
||||||
log.warning("[%s] DC%d%s WS handshake: %s",
|
log.warning("[%s] DC%d%s WS handshake: %s",
|
||||||
label, dc, media_tag, exc.status_line)
|
label, dc, media_tag, exc.status_line)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stats.ws_errors += 1
|
||||||
|
ws_timed_out = True
|
||||||
|
log.warning("[%s] DC%d%s WS connect timed out via %s",
|
||||||
|
label, dc, media_tag, domain)
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
stats.ws_errors += 1
|
stats.ws_errors += 1
|
||||||
all_redirects = False
|
all_redirects = False
|
||||||
log.warning("[%s] DC%d%s WS connect failed: %s",
|
log.warning("[%s] DC%d%s WS connect failed: %s",
|
||||||
label, dc, media_tag, repr(exc))
|
label, dc, media_tag, repr(exc))
|
||||||
|
|
||||||
|
# Fronting fallback if WS timed out
|
||||||
|
# TODO: Move fronting logic into bridge.py where other fallbacks are handled
|
||||||
|
# and don't forget about WsPool fronting fallback
|
||||||
|
if ws is None and ws_timed_out and not fronting_active:
|
||||||
|
log.info("[%s] DC%d%s -> fronting fallback (Host %s)",
|
||||||
|
label, dc, media_tag, domains[0])
|
||||||
|
try:
|
||||||
|
ws = await RawWebSocket.connect(target, domains[0],
|
||||||
|
timeout=5.0,
|
||||||
|
sni="sprinthost.ru")
|
||||||
|
except Exception as exc:
|
||||||
|
stats.ws_errors += 1
|
||||||
|
log.warning("[%s] DC%d%s fronting failed: %s",
|
||||||
|
label, dc, media_tag, repr(exc))
|
||||||
|
if ws:
|
||||||
|
fronting_until = now + FRONTING_COOLDOWN
|
||||||
|
ws_pool.fronting_until = now + FRONTING_COOLDOWN
|
||||||
|
stats.connections_fronting += 1
|
||||||
|
log.info("[%s] DC%d%s fronting OK for %ds",
|
||||||
|
label, dc, media_tag, int(FRONTING_COOLDOWN))
|
||||||
|
|
||||||
# WS failed -> fallback
|
# WS failed -> fallback
|
||||||
if ws is None:
|
if ws is None:
|
||||||
|
if ws_timed_out:
|
||||||
|
ip_fail_until[target] = now + IP_FAIL_COOLDOWN
|
||||||
|
|
||||||
if ws_failed_redirect and all_redirects:
|
if ws_failed_redirect and all_redirects:
|
||||||
ws_blacklist.add(dc_key)
|
ws_blacklist.add(dc_key)
|
||||||
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
|
log.warning("[%s] DC%d%s blacklisted for WS (all 302)",
|
||||||
@@ -487,6 +448,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
|||||||
return
|
return
|
||||||
|
|
||||||
dc_fail_until.pop(dc_key, None)
|
dc_fail_until.pop(dc_key, None)
|
||||||
|
ip_fail_until.pop(target, None)
|
||||||
stats.connections_ws += 1
|
stats.connections_ws += 1
|
||||||
|
|
||||||
splitter = None
|
splitter = None
|
||||||
@@ -533,18 +495,21 @@ _client_tasks: Set[asyncio.Task] = set()
|
|||||||
|
|
||||||
|
|
||||||
async def _run(stop_event: Optional[asyncio.Event] = None):
|
async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||||
global _server_instance, _server_stop_event
|
global _server_instance, _server_stop_event, fronting_until
|
||||||
_server_stop_event = stop_event
|
_server_stop_event = stop_event
|
||||||
|
|
||||||
_ws_pool.reset()
|
ws_pool.reset()
|
||||||
|
cf_worker_pool.reset()
|
||||||
ws_blacklist.clear()
|
ws_blacklist.clear()
|
||||||
dc_fail_until.clear()
|
dc_fail_until.clear()
|
||||||
|
ip_fail_until.clear()
|
||||||
_client_tasks.clear()
|
_client_tasks.clear()
|
||||||
|
fronting_until = 0.0
|
||||||
|
|
||||||
if proxy_config.fallback_cfproxy:
|
if proxy_config.fallback_cfproxy:
|
||||||
user = proxy_config.cfproxy_user_domain
|
user = proxy_config.cfproxy_user_domains
|
||||||
if user:
|
if user:
|
||||||
balancer.update_domains_list([user])
|
balancer.update_domains_list(user)
|
||||||
else:
|
else:
|
||||||
start_cfproxy_domain_refresh()
|
start_cfproxy_domain_refresh()
|
||||||
|
|
||||||
@@ -587,11 +552,11 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
ip = proxy_config.dc_redirects.get(dc)
|
ip = proxy_config.dc_redirects.get(dc)
|
||||||
log.info(" DC%d: %s", dc, ip)
|
log.info(" DC%d: %s", dc, ip)
|
||||||
if proxy_config.fallback_cfproxy:
|
if proxy_config.fallback_cfproxy:
|
||||||
user_domain = "user" if proxy_config.cfproxy_user_domain else "auto"
|
user_domain = "user" if proxy_config.cfproxy_user_domains else "auto"
|
||||||
log.info(" CF proxy: enabled (%s)", user_domain)
|
log.info(" CF proxy: enabled (%s)", user_domain)
|
||||||
if proxy_config.cfproxy_worker_domain:
|
if proxy_config.cfproxy_worker_domains:
|
||||||
log.info(" CF worker: enabled (%s)",
|
log.info(" CF worker: enabled (%s)",
|
||||||
proxy_config.cfproxy_worker_domain)
|
", ".join(proxy_config.cfproxy_worker_domains))
|
||||||
log.info("=" * 60)
|
log.info("=" * 60)
|
||||||
log.info(" Connect:")
|
log.info(" Connect:")
|
||||||
if ftls:
|
if ftls:
|
||||||
@@ -611,40 +576,86 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
|||||||
|
|
||||||
log_stats_task = asyncio.create_task(log_stats())
|
log_stats_task = asyncio.create_task(log_stats())
|
||||||
|
|
||||||
await _ws_pool.warmup(proxy_config.dc_redirects)
|
await ws_pool.warmup()
|
||||||
|
await cf_worker_pool.warmup()
|
||||||
|
|
||||||
|
async def _quiet_cancel(t):
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with server:
|
while True:
|
||||||
if stop_event:
|
serve_task = asyncio.create_task(server.serve_forever())
|
||||||
serve_task = asyncio.create_task(server.serve_forever())
|
stop_task = (asyncio.create_task(stop_event.wait())
|
||||||
stop_task = asyncio.create_task(stop_event.wait())
|
if stop_event else None)
|
||||||
done, _ = await asyncio.wait(
|
|
||||||
(serve_task, stop_task),
|
async def _listener_watchdog():
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
while True:
|
||||||
)
|
await asyncio.sleep(LISTENER_CHECK_INTERVAL)
|
||||||
if stop_task in done:
|
socks = server.sockets
|
||||||
server.close()
|
if not socks or all(s.fileno() < 0 for s in socks):
|
||||||
await server.wait_closed()
|
return
|
||||||
if not serve_task.done():
|
|
||||||
serve_task.cancel()
|
watchdog_task = asyncio.create_task(_listener_watchdog())
|
||||||
try:
|
waiters = [serve_task, watchdog_task]
|
||||||
await serve_task
|
if stop_task is not None:
|
||||||
except asyncio.CancelledError:
|
waiters.append(stop_task)
|
||||||
pass
|
|
||||||
else:
|
done, _ = await asyncio.wait(
|
||||||
stop_task.cancel()
|
waiters, return_when=asyncio.FIRST_COMPLETED)
|
||||||
try:
|
|
||||||
await stop_task
|
if stop_task is not None and stop_task in done:
|
||||||
except asyncio.CancelledError:
|
for task in list(_client_tasks):
|
||||||
pass
|
task.cancel()
|
||||||
else:
|
if _client_tasks:
|
||||||
await server.serve_forever()
|
await asyncio.gather(
|
||||||
|
*_client_tasks, return_exceptions=True)
|
||||||
|
await _quiet_cancel(watchdog_task)
|
||||||
|
await _quiet_cancel(serve_task)
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
break
|
||||||
|
|
||||||
|
await _quiet_cancel(watchdog_task)
|
||||||
|
await _quiet_cancel(serve_task)
|
||||||
|
log.warning(
|
||||||
|
"Listening socket died, restarting server")
|
||||||
|
server.close()
|
||||||
|
try:
|
||||||
|
await server.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(LISTENER_RESTART_DELAY)
|
||||||
|
try:
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
client_cb, proxy_config.host, proxy_config.port)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("Failed to restart server: %s", repr(exc))
|
||||||
|
break
|
||||||
|
_server_instance = server
|
||||||
|
for sock in server.sockets:
|
||||||
|
try:
|
||||||
|
sock.setsockopt(
|
||||||
|
_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
log.warning("Server restored, listening on %s:%d",
|
||||||
|
proxy_config.host, proxy_config.port)
|
||||||
finally:
|
finally:
|
||||||
log_stats_task.cancel()
|
log_stats_task.cancel()
|
||||||
try:
|
try:
|
||||||
await log_stats_task
|
await log_stats_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
server.close()
|
||||||
|
await server.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_server_instance = None
|
_server_instance = None
|
||||||
|
|
||||||
|
|
||||||
@@ -670,19 +681,22 @@ 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',
|
||||||
help='WS connection pool size per DC (default 4, min 0)')
|
help='WS connection pool size per DC (default 4, min 0)')
|
||||||
ap.add_argument('--cfproxy-domain', type=str, default='',
|
ap.add_argument('--cfproxy-domain', action='append', default=None,
|
||||||
metavar='DOMAIN',
|
metavar='DOMAIN',
|
||||||
help='User defined Cloudflare-proxied domain for WS fallback')
|
help='User defined Cloudflare-proxied domain for WS fallback '
|
||||||
ap.add_argument('--cfproxy-worker-domain', type=str, default='',
|
'(repeatable for multiple domains)')
|
||||||
|
ap.add_argument('--cfproxy-worker-domain', action='append', default=None,
|
||||||
metavar='DOMAIN',
|
metavar='DOMAIN',
|
||||||
help='Cloudflare Worker domain for WS fallback '
|
help='Cloudflare Worker domain for WS fallback '
|
||||||
'(tried before other fallback methods)')
|
'(tried before other fallback methods, '
|
||||||
|
'repeatable for multiple domains)')
|
||||||
ap.add_argument('--no-cfproxy', action='store_true',
|
ap.add_argument('--no-cfproxy', action='store_true',
|
||||||
help='Disable Cloudflare proxy fallback')
|
help='Disable Cloudflare proxy fallback')
|
||||||
ap.add_argument('--fake-tls-domain', type=str, default='',
|
ap.add_argument('--fake-tls-domain', type=str, default='',
|
||||||
@@ -724,8 +738,8 @@ def main():
|
|||||||
proxy_config.buffer_size = max(4, args.buf_kb) * 1024
|
proxy_config.buffer_size = max(4, args.buf_kb) * 1024
|
||||||
proxy_config.pool_size = max(0, args.pool_size)
|
proxy_config.pool_size = max(0, args.pool_size)
|
||||||
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
proxy_config.fallback_cfproxy = not args.no_cfproxy
|
||||||
proxy_config.cfproxy_user_domain = args.cfproxy_domain.strip()
|
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
|
||||||
proxy_config.cfproxy_worker_domain = args.cfproxy_worker_domain.strip()
|
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
||||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||||
proxy_config.proxy_protocol = args.proxy_protocol
|
proxy_config.proxy_protocol = args.proxy_protocol
|
||||||
|
|
||||||
@@ -740,11 +754,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)
|
||||||
|
|||||||
+18
-1
@@ -2,7 +2,7 @@ import socket as _socket
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import http.client
|
import http.client
|
||||||
|
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict, List
|
||||||
from urllib.request import Request
|
from urllib.request import Request
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +34,23 @@ _GITHUB_IPS: Dict[str, str] = {
|
|||||||
"raw.githubusercontent.com": "185.199.109.133",
|
"raw.githubusercontent.com": "185.199.109.133",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DC_DEFAULT_IPS: Dict[int, str] = {
|
||||||
|
1: '149.154.175.50',
|
||||||
|
2: '149.154.167.51',
|
||||||
|
3: '149.154.175.100',
|
||||||
|
4: '149.154.167.91',
|
||||||
|
5: '149.154.171.5',
|
||||||
|
203: '91.105.192.100'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ws_domains(dc: int, is_media) -> List[str]:
|
||||||
|
if dc == 203:
|
||||||
|
dc = 2
|
||||||
|
if is_media is None or is_media:
|
||||||
|
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||||
|
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
||||||
|
|
||||||
|
|
||||||
def human_bytes(n: int) -> str:
|
def human_bytes(n: int) -> str:
|
||||||
for unit in ('B', 'KB', 'MB', 'GB'):
|
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||||
|
|||||||
+281
-190
@@ -6,7 +6,7 @@ import webbrowser
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list
|
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
|
||||||
from proxy.balancer import balancer
|
from proxy.balancer import balancer
|
||||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||||
|
|
||||||
@@ -17,61 +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"
|
|
||||||
"Если не указан — выбирается автоматически из поддерживаемых доменов"
|
|
||||||
)
|
|
||||||
_TIP_CFPROXY_USER_DOMAIN_CB = (
|
|
||||||
"Указать свой домен вместо автоматического выбора"
|
|
||||||
)
|
|
||||||
_TIP_CFWORKER_DOMAIN = (
|
|
||||||
"Домен Cloudflare Worker (например, name.account.workers.dev).\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]
|
||||||
@@ -121,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
|
||||||
@@ -149,6 +104,14 @@ def _run_cfworker_connectivity_test(domain: str) -> dict:
|
|||||||
return _run_connectivity_test(cases)
|
return _run_connectivity_test(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfproxy_multi_test(domains: list) -> dict:
|
||||||
|
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cfworker_multi_test(domains: list) -> dict:
|
||||||
|
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
|
||||||
|
|
||||||
|
|
||||||
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
def _run_cfproxy_auto_test(domains: list) -> tuple:
|
||||||
merged: dict = {}
|
merged: dict = {}
|
||||||
best_domain = None
|
best_domain = None
|
||||||
@@ -173,30 +136,79 @@ 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.withdraw()
|
||||||
|
try:
|
||||||
|
root.attributes("-topmost", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_mb.showinfo(title, msg, parent=root)
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def _show_multi_connectivity_results(title_base: str, per_domain: dict,
|
||||||
|
label_prefix: str = 'DC') -> None:
|
||||||
|
import tkinter as _tk
|
||||||
|
from tkinter import messagebox as _mb
|
||||||
|
|
||||||
|
total = len(_CFPROXY_TEST_DCS)
|
||||||
|
all_ok = True
|
||||||
|
any_ok = False
|
||||||
|
blocks = []
|
||||||
|
for domain, results in per_domain.items():
|
||||||
|
ok = [dc for dc, v in results.items() if v is True]
|
||||||
|
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||||
|
if len(ok) == total:
|
||||||
|
any_ok = True
|
||||||
|
blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total))
|
||||||
|
elif not ok:
|
||||||
|
all_ok = False
|
||||||
|
blocks.append(t("connectivity.multi_fail", domain=domain))
|
||||||
|
else:
|
||||||
|
all_ok = False
|
||||||
|
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(
|
||||||
|
t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_ok:
|
||||||
|
title = t("connectivity.all_ok", title=title_base)
|
||||||
|
elif any_ok:
|
||||||
|
title = t("connectivity.partial", title=title_base)
|
||||||
|
else:
|
||||||
|
title = t("connectivity.unavailable", title=title_base)
|
||||||
|
msg = "\n\n".join(blocks)
|
||||||
|
|
||||||
root = _tk.Tk()
|
root = _tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
@@ -209,12 +221,32 @@ def _show_connectivity_results(title_base: str, results: 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,
|
||||||
@@ -279,6 +311,10 @@ def tray_settings_scroll_and_footer(
|
|||||||
scrollbar_button_hover_color=theme.text_secondary,
|
scrollbar_button_hover_color=theme.text_secondary,
|
||||||
)
|
)
|
||||||
scroll.pack(fill="both", expand=True)
|
scroll.pack(fill="both", expand=True)
|
||||||
|
try:
|
||||||
|
scroll._parent_canvas.configure(yscrollincrement=4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return scroll, footer
|
return scroll, footer
|
||||||
|
|
||||||
|
|
||||||
@@ -318,6 +354,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(
|
||||||
@@ -329,11 +366,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")
|
||||||
@@ -342,35 +383,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,
|
||||||
@@ -382,22 +404,79 @@ 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)
|
||||||
|
|
||||||
|
_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")
|
||||||
|
|
||||||
@@ -405,9 +484,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))
|
||||||
|
|
||||||
@@ -422,8 +501,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,
|
||||||
@@ -433,9 +512,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))
|
||||||
@@ -443,34 +522,37 @@ 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]
|
||||||
|
|
||||||
def _on_cf_test():
|
def _on_cf_test():
|
||||||
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else ""
|
user_domains = (
|
||||||
|
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||||
|
if cf_custom_cb_var.get() else []
|
||||||
|
)
|
||||||
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_domain:
|
if user_domains:
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
res = _run_cfproxy_connectivity_test(user_domain)
|
per = _run_cfproxy_multi_test(user_domains)
|
||||||
if btn:
|
if btn:
|
||||||
btn.after(
|
btn.after(
|
||||||
0,
|
0,
|
||||||
lambda: _show_connectivity_results(
|
lambda: _show_multi_connectivity_results(
|
||||||
"CF-прокси", res, domain=user_domain, 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():
|
||||||
@@ -480,23 +562,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,
|
||||||
@@ -508,11 +588,13 @@ def install_tray_config_form(
|
|||||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||||
cf_custom_row.pack(fill="x")
|
cf_custom_row.pack(fill="x")
|
||||||
|
|
||||||
saved_user_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
saved_user_domains = coerce_domain_list(
|
||||||
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain))
|
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
|
)
|
||||||
|
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
|
||||||
|
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,
|
||||||
@@ -522,13 +604,13 @@ def install_tray_config_form(
|
|||||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||||
).pack(side="right")
|
).pack(side="right")
|
||||||
|
|
||||||
cfproxy_user_domain_var = ctk.StringVar(value=saved_user_domain)
|
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
||||||
cf_domain_entry = _entry(
|
cf_domain_entry = _entry(
|
||||||
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||||
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"
|
||||||
@@ -539,25 +621,27 @@ 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")
|
||||||
cf_worker_input.pack(fill="x")
|
cf_worker_input.pack(fill="x")
|
||||||
|
|
||||||
cfproxy_worker_domain_var = ctk.StringVar(
|
cfproxy_worker_domain_var = ctk.StringVar(
|
||||||
value=cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
value=", ".join(coerce_domain_list(
|
||||||
|
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||||
|
))
|
||||||
)
|
)
|
||||||
cf_worker_entry = _entry(
|
cf_worker_entry = _entry(
|
||||||
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||||
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]
|
||||||
|
|
||||||
@@ -565,30 +649,30 @@ def install_tray_config_form(
|
|||||||
btn = _cfworker_test_btn[0]
|
btn = _cfworker_test_btn[0]
|
||||||
if btn is None:
|
if btn is None:
|
||||||
return
|
return
|
||||||
enabled = bool(cfproxy_worker_domain_var.get().strip())
|
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||||
btn.configure(state="normal" if enabled else "disabled")
|
btn.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
def _on_cfworker_test():
|
def _on_cfworker_test():
|
||||||
domain = cfproxy_worker_domain_var.get().strip()
|
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||||
btn = _cfworker_test_btn[0]
|
btn = _cfworker_test_btn[0]
|
||||||
if not domain 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():
|
||||||
try:
|
try:
|
||||||
res = _run_cfworker_connectivity_test(domain)
|
per = _run_cfworker_multi_test(domains)
|
||||||
btn.after(
|
btn.after(
|
||||||
0,
|
0,
|
||||||
lambda: _show_connectivity_results(
|
lambda: _show_multi_connectivity_results(
|
||||||
"CF Worker", res, domain=domain, 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()
|
||||||
@@ -602,7 +686,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,
|
||||||
@@ -613,20 +697,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")
|
||||||
@@ -643,38 +727,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,
|
||||||
@@ -684,17 +762,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,
|
||||||
@@ -705,6 +783,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -725,6 +804,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,
|
||||||
@@ -737,14 +826,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()
|
||||||
@@ -754,15 +843,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,
|
||||||
@@ -784,11 +873,13 @@ def validate_config_form(
|
|||||||
if widgets.cfproxy_var is not None:
|
if widgets.cfproxy_var is not None:
|
||||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||||
if widgets.cfproxy_user_domain_var is not None:
|
if widgets.cfproxy_user_domain_var is not None:
|
||||||
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip()
|
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
||||||
if widgets.cfproxy_worker_domain_var is not None:
|
if widgets.cfproxy_worker_domain_var is not None:
|
||||||
new_cfg["cfproxy_worker_domain"] = widgets.cfproxy_worker_domain_var.get().strip()
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -809,22 +900,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(
|
||||||
@@ -849,19 +940,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(
|
||||||
@@ -893,13 +984,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",
|
||||||
|
|||||||
@@ -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
@@ -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 web.telegram.org dc 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 connection fails then fallbacks are used",
|
||||||
|
"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 10000–50000 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
@@ -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": "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Это предупреждение будет показано только один раз."
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -18,14 +20,16 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
|||||||
"buf_kb": 256,
|
"buf_kb": 256,
|
||||||
"pool_size": 4,
|
"pool_size": 4,
|
||||||
"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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
+96
-39
@@ -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
|
||||||
@@ -14,16 +14,19 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
|
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
|
||||||
@@ -271,8 +332,9 @@ def apply_proxy_config(cfg: dict) -> bool:
|
|||||||
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
|
||||||
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
|
||||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||||
pc.cfproxy_user_domain = 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_domain = 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:
|
||||||
|
|||||||
+87
-39
@@ -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,48 +224,101 @@ 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
|
||||||
h = hashlib.sha256()
|
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||||
with open(exe_path, "rb") as f:
|
if code == 200 and data:
|
||||||
while True:
|
cur_assets = _extract_assets(data)
|
||||||
chunk = f.read(65536)
|
if cur_assets:
|
||||||
if not chunk:
|
h = hashlib.sha256()
|
||||||
break
|
with open(exe_path, "rb") as f:
|
||||||
h.update(chunk)
|
while True:
|
||||||
exe_sha = h.hexdigest().lower()
|
chunk = f.read(65536)
|
||||||
for a in assets:
|
if not chunk:
|
||||||
d = (a.get("digest") or "").lower()
|
break
|
||||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
h.update(chunk)
|
||||||
return a["url"], a["name"]
|
exe_sha = h.hexdigest().lower()
|
||||||
|
for a in cur_assets:
|
||||||
|
d = (a.get("digest") or "").lower()
|
||||||
|
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||||
|
target_name = a["name"]
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback
|
# Fallback
|
||||||
import struct
|
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||||
is_64 = struct.calcsize("P") * 8 == 64
|
import platform
|
||||||
try:
|
import struct
|
||||||
is_modern = sys.getwindowsversion().major >= 10
|
|
||||||
except Exception:
|
is_64 = struct.calcsize("P") * 8 == 64
|
||||||
is_modern = True
|
machine = platform.machine().lower()
|
||||||
if is_modern:
|
is_arm64 = machine in ("arm64", "aarch64")
|
||||||
name = "TgWsProxy_windows.exe"
|
|
||||||
elif is_64:
|
try:
|
||||||
name = "TgWsProxy_windows_7_64bit.exe"
|
is_modern = sys.getwindowsversion().major >= 10
|
||||||
else:
|
except Exception:
|
||||||
name = "TgWsProxy_windows_7_32bit.exe"
|
is_modern = True
|
||||||
for a in assets:
|
|
||||||
if a.get("name") == name:
|
if is_arm64:
|
||||||
|
target_name = "TgWsProxy_windows_arm64.exe"
|
||||||
|
elif is_modern:
|
||||||
|
target_name = "TgWsProxy_windows.exe"
|
||||||
|
elif is_64:
|
||||||
|
target_name = "TgWsProxy_windows_7_64bit.exe"
|
||||||
|
else:
|
||||||
|
target_name = "TgWsProxy_windows_7_32bit.exe"
|
||||||
|
|
||||||
|
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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user