mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-27 17:01:07 +03:00
Compare commits
47 Commits
main
..
c3794ed024
| Author | SHA1 | Date | |
|---|---|---|---|
| c3794ed024 | |||
| a5641700ed | |||
| 509f50fcae | |||
| e511ff597b | |||
| 3552de7dbf | |||
| 7c8bc17db6 | |||
| 76b375bd03 | |||
| 7ad377c12c | |||
| 7e9acc47fc | |||
| 0302a3b817 | |||
| 810991ea18 | |||
| 1599b1126c | |||
| 9e2c8c16ff | |||
| 79840806f2 | |||
| 68a378bad9 | |||
| 336602e93a | |||
| 934eb345a2 | |||
| 54b86cd9e2 | |||
| 4f65813785 | |||
| e50ea25d91 | |||
| e6cceac19f | |||
| faea437556 | |||
| b5b6a8021e | |||
| 85b111d0f3 | |||
| 3d10eb9113 | |||
| 30f902e0fb | |||
| 09fbc5d876 | |||
| ee4d41ab9c | |||
| cf758f6d68 | |||
| 6cbec90360 | |||
| bf21bbfa95 | |||
| a57f238d3c | |||
| c61e2e84ed | |||
| 61713703f8 | |||
| da15296f66 | |||
| 8d43fa25fa | |||
| db5a6cc696 | |||
| c5f8b40570 | |||
| 360ea20902 | |||
| fe55624e24 | |||
| ec6de3afb3 | |||
| 47e5c6241d | |||
| ec70188385 | |||
| ecc89d45d6 | |||
| 5e6fbdffda | |||
| 7dc9b04016 | |||
| 1a12548daf |
@@ -1,9 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.py text diff=python
|
||||
*.spec text linguist-language=Python
|
||||
|
||||
*.toml text
|
||||
*.txt text
|
||||
|
||||
*.ico binary
|
||||
@@ -1,11 +0,0 @@
|
||||
# Default owners
|
||||
* @Flowseal
|
||||
|
||||
# Automation and repository settings
|
||||
.github/** @Flowseal
|
||||
|
||||
# Documentation
|
||||
docs/** @Flowseal
|
||||
|
||||
# Core proxy implementation
|
||||
proxy/** @Flowseal
|
||||
@@ -1 +0,0 @@
|
||||
custom: ['https://nowpayments.io/donation/flowseal']
|
||||
@@ -1,23 +1,20 @@
|
||||
name: 🐛 Проблема
|
||||
title: '[Проблема] '
|
||||
description: Сообщить о проблеме
|
||||
labels: ['bug']
|
||||
labels: ['type: проблема', 'status: нуждается в сортировке']
|
||||
|
||||
body:
|
||||
- type: input
|
||||
id: app_version
|
||||
attributes:
|
||||
label: Версия TG WS Proxy
|
||||
description: Укажите версию приложения (например, v1.2.3)
|
||||
placeholder: vX.Y.Z
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Опишите вашу проблему
|
||||
description: Чётко опишите проблему, с которой вы столкнулись
|
||||
description: Чётко опишите проблему с которой вы столкнулись
|
||||
placeholder: Описание проблемы
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additions
|
||||
attributes:
|
||||
label: Дополнительные детали
|
||||
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.
|
||||
@@ -1,6 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: 📚 Документация
|
||||
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
|
||||
about: Ознакомьтесь с документацией перед созданием issue
|
||||
@@ -1,37 +0,0 @@
|
||||
name: 🚀 Предложение
|
||||
title: '[Предложение] '
|
||||
description: Предложить улучшение или новую функциональность
|
||||
labels: ['enhancement']
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Предлагаемое решение
|
||||
description: Опишите, как именно вы предлагаете улучшить проект
|
||||
placeholder: |
|
||||
Предлагаю добавить ...
|
||||
Это позволит ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Для какой платформы актуально?
|
||||
description: Выберите платформу, если предложение связано с конкретной ОС
|
||||
options:
|
||||
- Все платформы
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Другое
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Дополнительный контекст
|
||||
description: Добавьте примеры, ссылки, скриншоты или другие детали
|
||||
placeholder: Любые дополнительные материалы по предложению
|
||||
@@ -1,20 +0,0 @@
|
||||
virkgj.com
|
||||
vmmzovy.com
|
||||
mkuosckvso.com
|
||||
zaewayzmplad.com
|
||||
twdmbzcm.com
|
||||
awzwsldi.com
|
||||
clngqrflngqin.com
|
||||
tjacxbqtj.com
|
||||
bxaxtxmrw.com
|
||||
dmohrsgmohcrwb.com
|
||||
vwbmtmoi.com
|
||||
khgrre.com
|
||||
ulihssf.com
|
||||
tmhqsdqmfpmk.com
|
||||
xwuwoqbm.com
|
||||
orgcnunpj.com
|
||||
zhkuldz.com
|
||||
zypoljnslxa.com
|
||||
efabnxaowuzs.com
|
||||
zaftuzsftqdq.com
|
||||
+145
-240
@@ -16,8 +16,11 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build-windows-x64:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -26,132 +29,27 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Setup MSVC 14.40 toolset
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
toolset: 14.40
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install .
|
||||
|
||||
- name: Build PyInstaller bootloader from source
|
||||
env:
|
||||
PYINSTALLER_COMPILE_BOOTLOADER: "1"
|
||||
run: |
|
||||
pip download --no-binary pyinstaller --no-deps --no-cache-dir -d pyinstaller_src "pyinstaller==6.10.0"
|
||||
pip install (Get-ChildItem pyinstaller_src\*.tar.gz).FullName
|
||||
- name: Install pyinstaller
|
||||
run: pip install "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:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
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.exe
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-windows-x64
|
||||
name: TgWsProxy
|
||||
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:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
@@ -176,26 +74,6 @@ jobs:
|
||||
- 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:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
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_7_${{ matrix.suffix }}.exe
|
||||
|
||||
@@ -272,10 +150,30 @@ jobs:
|
||||
python3.12 -m pip install .
|
||||
python3.12 -m pip install pyinstaller==6.13.0
|
||||
|
||||
- name: Create macOS icon
|
||||
- name: Create macOS icon from ICO
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3.12 macos.py --render-app-icon icon.icns
|
||||
python3.12 - <<'PY'
|
||||
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
|
||||
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||
@@ -283,11 +181,6 @@ jobs:
|
||||
- name: Validate universal2 app bundle
|
||||
run: |
|
||||
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
|
||||
while IFS= read -r -d '' file; do
|
||||
if file "$file" | grep -q "Mach-O"; then
|
||||
@@ -311,31 +204,22 @@ jobs:
|
||||
- name: Create DMG
|
||||
run: |
|
||||
set -euo pipefail
|
||||
packaging/dmg/build_dmg.sh \
|
||||
"dist/TG WS Proxy.app" \
|
||||
"TG WS Proxy" \
|
||||
APP_NAME="TG WS Proxy"
|
||||
DMG_TEMP="dist/dmg_temp"
|
||||
|
||||
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"
|
||||
|
||||
- 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
|
||||
rm -rf "$DMG_TEMP"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -428,76 +312,6 @@ jobs:
|
||||
dpkg-deb --build --root-owner-group \
|
||||
"$PKG_ROOT" \
|
||||
"dist/TgWsProxy_linux_amd64.deb"
|
||||
|
||||
- name: Create .rpm package with fpm
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
sudo gem install fpm -v 1.17.0
|
||||
|
||||
mkdir -p rpm_package/usr/bin
|
||||
mkdir -p rpm_package/usr/share/applications
|
||||
mkdir -p rpm_package/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
cp dist/TgWsProxy_linux_amd64 rpm_package/usr/bin/tg-ws-proxy
|
||||
chmod 755 rpm_package/usr/bin/tg-ws-proxy
|
||||
|
||||
.venv/bin/python - <<PY
|
||||
from PIL import Image
|
||||
Image.open("icon.ico").save(
|
||||
"rpm_package/usr/share/icons/hicolor/256x256/apps/tg-ws-proxy.png",
|
||||
"PNG",
|
||||
)
|
||||
PY
|
||||
|
||||
cat > rpm_package/usr/share/applications/tg-ws-proxy.desktop <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=TG WS Proxy
|
||||
GenericName=Telegram Proxy
|
||||
Comment=Telegram Desktop WebSocket Bridge Proxy
|
||||
Exec=tg-ws-proxy
|
||||
Icon=tg-ws-proxy
|
||||
Terminal=false
|
||||
Categories=Network;
|
||||
StartupNotify=true
|
||||
Keywords=telegram;proxy;websocket;
|
||||
EOF
|
||||
|
||||
cat > post_install.sh <<EOF
|
||||
#!/bin/bash
|
||||
if [ -x /usr/bin/update-desktop-database ]; then
|
||||
/usr/bin/update-desktop-database &> /dev/null || :
|
||||
fi
|
||||
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||
/usr/bin/gtk-update-icon-cache -q /usr/share/icons/hicolor &> /dev/null || :
|
||||
fi
|
||||
EOF
|
||||
|
||||
chmod +x post_install.sh
|
||||
|
||||
fpm -s dir \
|
||||
-t rpm \
|
||||
-n tg-ws-proxy \
|
||||
-v ${VERSION} \
|
||||
--iteration 1 \
|
||||
--architecture x86_64 \
|
||||
--license "MIT" \
|
||||
--vendor "Flowseal" \
|
||||
--maintainer "Flowseal" \
|
||||
--url "https://github.com/Flowseal/tg-ws-proxy" \
|
||||
--description "MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI." \
|
||||
--depends "libgtk-3.so.0()(64bit)" \
|
||||
--depends "libayatana-appindicator3.so.1()(64bit)" \
|
||||
--depends "python3-tkinter" \
|
||||
--after-install post_install.sh \
|
||||
--after-remove post_install.sh \
|
||||
-C rpm_package \
|
||||
.
|
||||
|
||||
mv tg-ws-proxy-${VERSION}-1.x86_64.rpm dist/TgWsProxy_linux_amd64.rpm
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -506,10 +320,100 @@ jobs:
|
||||
path: |
|
||||
dist/TgWsProxy_linux_amd64
|
||||
dist/TgWsProxy_linux_amd64.deb
|
||||
dist/TgWsProxy_linux_amd64.rpm
|
||||
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ANDROID_APK_STANDARD_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk
|
||||
ANDROID_APK_LEGACY32_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}-legacy32.apk
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Android release signing secrets
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
test -n "$ANDROID_KEYSTORE_BASE64" || { echo "Missing secret: ANDROID_KEYSTORE_BASE64"; exit 1; }
|
||||
test -n "$ANDROID_KEYSTORE_PASSWORD" || { echo "Missing secret: ANDROID_KEYSTORE_PASSWORD"; exit 1; }
|
||||
test -n "$ANDROID_KEY_ALIAS" || { echo "Missing secret: ANDROID_KEY_ALIAS"; exit 1; }
|
||||
test -n "$ANDROID_KEY_PASSWORD" || { echo "Missing secret: ANDROID_KEY_PASSWORD"; exit 1; }
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
android/settings.gradle.kts
|
||||
android/build.gradle.kts
|
||||
android/gradle.properties
|
||||
android/app/build.gradle.kts
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Accept Android SDK licenses
|
||||
run: yes | sdkmanager --licenses > /dev/null
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: sdkmanager "platforms;android-34" "build-tools;34.0.0"
|
||||
|
||||
- name: Prepare Android release keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/android-release.keystore"
|
||||
test -s "$RUNNER_TEMP/android-release.keystore"
|
||||
|
||||
- name: Build Android release APKs
|
||||
env:
|
||||
LOCAL_CHAQUOPY_REPO: ${{ github.workspace }}/android/.m2-chaquopy-ci
|
||||
ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/android-release.keystore
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x gradlew build-local-debug.sh
|
||||
./build-local-debug.sh assembleStandardRelease
|
||||
./build-local-debug.sh assembleLegacy32Release
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
cp app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
"app/build/outputs/apk/standard/release/$ANDROID_APK_STANDARD_NAME"
|
||||
cp app/build/outputs/apk/legacy32/release/app-legacy32-release.apk \
|
||||
"app/build/outputs/apk/legacy32/release/$ANDROID_APK_LEGACY32_NAME"
|
||||
|
||||
- name: Stage Android release artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "app/build/outputs/apk/standard/release/$ANDROID_APK_STANDARD_NAME" "dist/$ANDROID_APK_STANDARD_NAME"
|
||||
cp "app/build/outputs/apk/legacy32/release/$ANDROID_APK_LEGACY32_NAME" "dist/$ANDROID_APK_LEGACY32_NAME"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-android-release
|
||||
path: |
|
||||
android/dist/${{ env.ANDROID_APK_STANDARD_NAME }}
|
||||
android/dist/${{ env.ANDROID_APK_LEGACY32_NAME }}
|
||||
|
||||
release:
|
||||
needs: [build-windows-x64, build-windows-arm64, build-win7, build-macos, build-linux]
|
||||
needs: [build-windows, build-win7, build-macos, build-linux, build-android]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.make_release == 'true' }}
|
||||
steps:
|
||||
@@ -519,27 +423,28 @@ jobs:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Android build
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: TgWsProxy-android-release
|
||||
path: dist
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||
body: |
|
||||
---
|
||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||
|
||||
> [!TIP]
|
||||
> Не можете скачать?
|
||||
> Добавьте `185.199.109.133 release-assets.githubusercontent.com` в hosts или воспользуйтесь зеркалом: https://sourceforge.net/projects/tg-ws-proxy.mirror/files/
|
||||
## TG WS Proxy ${{ github.event.inputs.version }}
|
||||
files: |
|
||||
dist/TgWsProxy_windows.exe
|
||||
dist/TgWsProxy_windows_arm64.exe
|
||||
dist/TgWsProxy_windows_7_64bit.exe
|
||||
dist/TgWsProxy_windows_7_32bit.exe
|
||||
dist/TgWsProxy_macos_universal.dmg
|
||||
dist/TgWsProxy_linux_amd64
|
||||
dist/TgWsProxy_linux_amd64.deb
|
||||
dist/TgWsProxy_linux_amd64.rpm
|
||||
dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk
|
||||
dist/tg-ws-proxy-android-${{ github.event.inputs.version }}-legacy32.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Auto comment on new issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: contains(github.event.issue.labels.*.name, 'bug')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Comment on new issue
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
### Проверьте две вещи:
|
||||
- вы на последней версии: [Releases](https://github.com/Flowseal/tg-ws-proxy/releases)
|
||||
- запускали по инструкции для своей ОС: [Быстрый старт](https://github.com/Flowseal/tg-ws-proxy#навигация)
|
||||
|
||||
## Решение частых проблем:
|
||||
|
||||
**Q**: Не запускается, падает с ошибкой, не работает как раньше после обновления?
|
||||
**A**:
|
||||
1. Удалите всё в папке Temp (или хотя бы всё, что начинается с _MEI)
|
||||
2. Запускайте от имени админа
|
||||
3. Попробуйте Win7 версию (если вы пользователь Windows)
|
||||
4. Попробуйте отключить антивирус (если помогло, то добавьте exe в исключения). Не забудьте включить антивирус обратно.
|
||||
|
||||
###
|
||||
|
||||
**Q**: Не грузит медиа? (фото/видео/стикеры)
|
||||
**A**: Удалите в настройках прокси в поле **DC → IP** всё, кроме `4:149.154.167.220`. Если это не помогло, полностью очистите это поле.
|
||||
|
||||
|
||||
#### Если проблема решена, то закройте Issue
|
||||
|
||||
### Если проблема осталась, пожалуйста, приложите по возможности логи.
|
||||
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
|
||||
+17
-2
@@ -6,8 +6,6 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.spec.bak
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
@@ -18,10 +16,27 @@ venv/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.gradle/
|
||||
.gradle-local/
|
||||
android/.gradle-local/
|
||||
android/.m2-chaquopy*/
|
||||
local.properties
|
||||
android/.idea/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/app/build
|
||||
android/*.jks
|
||||
*.keystore
|
||||
android/*.keystore.properties
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Project-specific (not for the repo)
|
||||
scan_ips.py
|
||||
scan.txt
|
||||
AyuGramDesktop-dev/
|
||||
tweb-master/
|
||||
/icon.icns
|
||||
|
||||
+4
-6
@@ -24,9 +24,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH=/opt/venv/bin:$PATH \
|
||||
TG_WS_PROXY_HOST=0.0.0.0 \
|
||||
TG_WS_PROXY_PORT=1443 \
|
||||
TG_WS_PROXY_SECRET="" \
|
||||
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" \
|
||||
TG_WS_PROXY_CF_WORKER=""
|
||||
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||
@@ -37,11 +35,11 @@ RUN apt-get update \
|
||||
WORKDIR /app
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY proxy ./proxy
|
||||
COPY docs/README.md LICENSE ./
|
||||
COPY README.md LICENSE ./
|
||||
|
||||
USER app
|
||||
|
||||
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; 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 []
|
||||
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; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
|
||||
CMD []
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
> [!CAUTION]
|
||||
>
|
||||
> ### Реакция антивирусов
|
||||
>
|
||||
> Windows Defender часто ошибочно помечает приложение как **Wacatac**.
|
||||
> Если вы не можете скачать из-за блокировки, то:
|
||||
>
|
||||
> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала)
|
||||
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||
>
|
||||
> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal**
|
||||
|
||||
# TG WS Proxy
|
||||
|
||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
|
||||
|
||||
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
|
||||
|
||||
## Как это работает
|
||||
|
||||
```
|
||||
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
|
||||
```
|
||||
|
||||
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
|
||||
2. Перехватывает подключения к IP-адресам Telegram
|
||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Windows
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
||||
|
||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей.
|
||||
|
||||
**Меню трея:**
|
||||
|
||||
- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
|
||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
|
||||
- **Открыть логи** — открыть файл логов
|
||||
- **Выход** — остановить прокси и закрыть приложение
|
||||
|
||||
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (отключается в настройках).
|
||||
|
||||
### macOS
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_macos_universal.dmg`** — универсальная сборка для Apple Silicon и Intel.
|
||||
|
||||
1. Открыть образ
|
||||
2. Перенести **TG WS Proxy.app** в папку **Applications**
|
||||
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
|
||||
|
||||
### Linux
|
||||
|
||||
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет **`TgWsProxy_linux_amd64.deb`**.
|
||||
|
||||
Для Arch и Arch-Based дистрибутивов подготовлены пакеты в AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
|
||||
|
||||
```shell
|
||||
# Установка без AUR-helper
|
||||
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
|
||||
cd tg-ws-proxy-bin
|
||||
makepkg -si
|
||||
|
||||
# При помощи AUR-helper
|
||||
paru -S tg-ws-proxy-bin
|
||||
|
||||
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
|
||||
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
|
||||
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
|
||||
```
|
||||
|
||||
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
|
||||
|
||||
```bash
|
||||
chmod +x TgWsProxy_linux_amd64
|
||||
./TgWsProxy_linux_amd64
|
||||
```
|
||||
|
||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||
|
||||
### Android
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте подписанный APK вида **`tg-ws-proxy-android-vX.Y.Z.apk`**.
|
||||
|
||||
После установки:
|
||||
|
||||
- откройте приложение
|
||||
- проверьте `Android background limits`
|
||||
- при необходимости отключите battery optimization и снимите background restrictions
|
||||
- нажмите **Start Service**
|
||||
- нажмите **Open in Telegram**
|
||||
|
||||
Что важно для стабильной работы на Android:
|
||||
|
||||
- разрешите уведомления
|
||||
- отключите battery optimization для приложения
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
### Консольный proxy
|
||||
|
||||
Для запуска только proxy без tray-интерфейса достаточно базовой установки:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy
|
||||
```
|
||||
|
||||
### Windows 7/10+
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-macos
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-linux
|
||||
```
|
||||
|
||||
### Консольный режим из исходников
|
||||
|
||||
```bash
|
||||
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
```
|
||||
|
||||
### Android debug APK
|
||||
|
||||
Требуются JDK 17, Android SDK и Gradle. Локальная debug-сборка:
|
||||
|
||||
```bash
|
||||
./android/build-local-debug.sh assembleStandardDebug
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
```text
|
||||
android/app/build/outputs/apk/standard/debug/app-standard-debug.apk
|
||||
```
|
||||
|
||||
**Аргументы:**
|
||||
|
||||
| Аргумент | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `--port` | `1443` | Порт прокси |
|
||||
| `--host` | `127.0.0.1` | Хост прокси |
|
||||
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
|
||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
|
||||
| `--buf-kb` | `256` | Размер буфера в КБ
|
||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC
|
||||
| `--log-file` | выкл. | Путь до файла, в который сохранять логи
|
||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись)
|
||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи
|
||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||
|
||||
**Примеры:**
|
||||
|
||||
```bash
|
||||
# Стандартный запуск
|
||||
tg-ws-proxy
|
||||
|
||||
# Другой порт и дополнительные DC
|
||||
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||
|
||||
# С подробным логированием
|
||||
tg-ws-proxy -v
|
||||
```
|
||||
|
||||
## CLI-скрипты (pyproject.toml)
|
||||
|
||||
CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`.
|
||||
|
||||
Пример:
|
||||
|
||||
```toml
|
||||
[project.scripts]
|
||||
tg-ws-proxy = "proxy.tg_ws_proxy:main"
|
||||
tg-ws-proxy-tray-win = "windows:main"
|
||||
tg-ws-proxy-tray-macos = "macos:main"
|
||||
tg-ws-proxy-tray-linux = "linux:main"
|
||||
```
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
### Автоматически
|
||||
|
||||
ПКМ по иконке в трее → **«Открыть в Telegram»**
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавить прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Настройка Telegram Android
|
||||
|
||||
### Автоматически
|
||||
|
||||
В приложении нажмите **Open in Telegram** после запуска foreground service.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Telegram → **Настройки** → **Данные и память** → **Настройки прокси**
|
||||
2. Добавить прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1`
|
||||
- **Порт:** `1443`
|
||||
- **Secret:** из настроек приложения
|
||||
|
||||
Важно:
|
||||
|
||||
- сначала должен быть запущен foreground service
|
||||
- если Telegram был уже открыт, иногда проще закрыть и открыть его заново после запуска прокси
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Tray-приложение хранит данные в:
|
||||
|
||||
- **Windows:** `%APPDATA%/TgWsProxy`
|
||||
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
||||
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1443,
|
||||
"secret": "...",
|
||||
"dc_ip": [
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220"
|
||||
],
|
||||
"verbose": false,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"log_max_mb": 5.0,
|
||||
"check_updates": true
|
||||
}
|
||||
```
|
||||
|
||||
Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему).
|
||||
|
||||
## Автоматическая сборка
|
||||
|
||||
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки.
|
||||
|
||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||
|
||||
- Windows 10+ для `TgWsProxy_windows.exe`
|
||||
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
||||
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
||||
- Intel macOS 10.15+
|
||||
- Apple Silicon macOS 11.0+
|
||||
- Linux x86_64 (требуется AppIndicator для системного трея)
|
||||
## Лицензия
|
||||
|
||||
[MIT License](LICENSE)
|
||||
@@ -0,0 +1,170 @@
|
||||
import org.gradle.api.tasks.Sync
|
||||
import org.gradle.api.GradleException
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.chaquo.python")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
fun loadProxyVersionName(): String {
|
||||
val versionFile = rootProject.projectDir.resolve("../proxy/__init__.py")
|
||||
val match = Regex("""__version__\s*=\s*"([^"]+)"""")
|
||||
.find(versionFile.readText())
|
||||
?: throw GradleException("Failed to parse proxy version from ${versionFile.absolutePath}")
|
||||
return match.groupValues[1]
|
||||
}
|
||||
|
||||
data class ReleaseSigningEnv(
|
||||
val keystoreFile: File,
|
||||
val storePassword: String,
|
||||
val keyAlias: String,
|
||||
val keyPassword: String,
|
||||
)
|
||||
|
||||
fun requiredEnv(name: String): String {
|
||||
return System.getenv(name)?.takeIf { it.isNotBlank() }
|
||||
?: throw GradleException("Missing required environment variable: $name")
|
||||
}
|
||||
|
||||
fun loadReleaseSigningEnv(releaseSigningRequested: Boolean): ReleaseSigningEnv? {
|
||||
val keystorePath = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
||||
val anySigningEnvProvided = listOf(
|
||||
keystorePath,
|
||||
System.getenv("ANDROID_KEYSTORE_PASSWORD"),
|
||||
System.getenv("ANDROID_KEY_ALIAS"),
|
||||
System.getenv("ANDROID_KEY_PASSWORD"),
|
||||
).any { !it.isNullOrBlank() }
|
||||
|
||||
if (!releaseSigningRequested && !anySigningEnvProvided) {
|
||||
return null
|
||||
}
|
||||
|
||||
val keystoreFile = File(requiredEnv("ANDROID_KEYSTORE_FILE"))
|
||||
if (!keystoreFile.isFile) {
|
||||
throw GradleException("ANDROID_KEYSTORE_FILE does not exist: ${keystoreFile.absolutePath}")
|
||||
}
|
||||
|
||||
return ReleaseSigningEnv(
|
||||
keystoreFile = keystoreFile,
|
||||
storePassword = requiredEnv("ANDROID_KEYSTORE_PASSWORD"),
|
||||
keyAlias = requiredEnv("ANDROID_KEY_ALIAS"),
|
||||
keyPassword = requiredEnv("ANDROID_KEY_PASSWORD"),
|
||||
)
|
||||
}
|
||||
|
||||
val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python")
|
||||
val stagePythonSources by tasks.registering(Sync::class) {
|
||||
from(rootProject.projectDir.resolve("../proxy")) {
|
||||
into("proxy")
|
||||
}
|
||||
from(rootProject.projectDir.resolve("../utils")) {
|
||||
into("utils")
|
||||
}
|
||||
into(stagedPythonSourcesDir)
|
||||
}
|
||||
val releaseSigningRequested = gradle.startParameter.taskNames.any {
|
||||
it.contains("release", ignoreCase = true)
|
||||
}
|
||||
val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested)
|
||||
val appVersionName = loadProxyVersionName()
|
||||
|
||||
android {
|
||||
namespace = "org.flowseal.tgwsproxy"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.flowseal.tgwsproxy"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = appVersionName
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
flavorDimensions += "runtime"
|
||||
productFlavors {
|
||||
create("standard") {
|
||||
dimension = "runtime"
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
create("legacy32") {
|
||||
dimension = "runtime"
|
||||
versionNameSuffix = "-legacy32"
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (releaseSigningEnv != null) {
|
||||
create("release") {
|
||||
storeFile = releaseSigningEnv.keystoreFile
|
||||
storePassword = releaseSigningEnv.storePassword
|
||||
keyAlias = releaseSigningEnv.keyAlias
|
||||
keyPassword = releaseSigningEnv.keyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (releaseSigningEnv != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
productFlavors {
|
||||
getByName("standard") {
|
||||
version = "3.12"
|
||||
}
|
||||
getByName("legacy32") {
|
||||
version = "3.11"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
srcDir("src/main/python")
|
||||
srcDir(stagePythonSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Intentionally empty for the initial Android shell.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_proxy_app"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_proxy_app"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TgWsProxy">
|
||||
|
||||
<activity
|
||||
android:name=".LogViewerActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".ProxyForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
data class AndroidSystemStatus(
|
||||
val ignoringBatteryOptimizations: Boolean,
|
||||
val backgroundRestricted: Boolean,
|
||||
) {
|
||||
val canKeepRunningReliably: Boolean
|
||||
get() = ignoringBatteryOptimizations && !backgroundRestricted
|
||||
|
||||
companion object {
|
||||
fun read(context: Context): AndroidSystemStatus {
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
|
||||
val ignoringBatteryOptimizations = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
powerManager.isIgnoringBatteryOptimizations(context.packageName)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
val backgroundRestricted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
activityManager.isBackgroundRestricted
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
return AndroidSystemStatus(
|
||||
ignoringBatteryOptimizations = ignoringBatteryOptimizations,
|
||||
backgroundRestricted = backgroundRestricted,
|
||||
)
|
||||
}
|
||||
|
||||
fun openBatteryOptimizationSettings(context: Context) {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
}
|
||||
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
fun openAppSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.flowseal.tgwsproxy.databinding.ActivityLogViewerBinding
|
||||
import java.io.File
|
||||
|
||||
class LogViewerActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityLogViewerBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityLogViewerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.refreshLogsButton.setOnClickListener { renderLog() }
|
||||
binding.closeLogsButton.setOnClickListener { finish() }
|
||||
|
||||
renderLog()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderLog()
|
||||
}
|
||||
|
||||
private fun renderLog() {
|
||||
val logFile = File(filesDir, "tg-ws-proxy/proxy.log")
|
||||
binding.logPathValue.text = logFile.absolutePath
|
||||
binding.logContentValue.text = readLogTail(logFile)
|
||||
}
|
||||
|
||||
private fun readLogTail(logFile: File, maxChars: Int = 40000): String {
|
||||
if (!logFile.isFile) {
|
||||
return getString(R.string.logs_empty)
|
||||
}
|
||||
|
||||
val text = runCatching {
|
||||
logFile.readText(Charsets.UTF_8)
|
||||
}.getOrElse { error ->
|
||||
return getString(R.string.logs_read_failed, error.message ?: error.javaClass.simpleName)
|
||||
}
|
||||
|
||||
if (text.isBlank()) {
|
||||
return getString(R.string.logs_empty)
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text
|
||||
}
|
||||
|
||||
return getString(R.string.logs_truncated_prefix) + "\n\n" + text.takeLast(maxChars)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var settingsStore: ProxySettingsStore
|
||||
private var currentUpdateStatus: ProxyUpdateStatus? = null
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (!granted) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Без уведомлений Android может скрыть foreground service.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
settingsStore = ProxySettingsStore(this)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.startButton.setOnClickListener { onStartClicked() }
|
||||
binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) }
|
||||
binding.restartButton.setOnClickListener { onRestartClicked() }
|
||||
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) }
|
||||
binding.openLogsButton.setOnClickListener { onOpenLogsClicked() }
|
||||
binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() }
|
||||
binding.openReleasePageButton.setOnClickListener { onOpenReleasePageClicked() }
|
||||
binding.checkUpdatesSwitch.setOnCheckedChangeListener { _, _ ->
|
||||
renderUpdateStatus(currentUpdateStatus, binding.checkUpdatesSwitch.isChecked)
|
||||
}
|
||||
binding.disableBatteryOptimizationButton.setOnClickListener {
|
||||
AndroidSystemStatus.openBatteryOptimizationSettings(this)
|
||||
}
|
||||
binding.openAppSettingsButton.setOnClickListener {
|
||||
AndroidSystemStatus.openAppSettings(this)
|
||||
}
|
||||
|
||||
val config = settingsStore.load()
|
||||
renderConfig(config)
|
||||
if (config.checkUpdates) {
|
||||
refreshUpdateStatus(checkNow = true)
|
||||
} else {
|
||||
currentUpdateStatus = null
|
||||
renderUpdateStatus(null, false)
|
||||
}
|
||||
requestNotificationPermissionIfNeeded()
|
||||
observeServiceState()
|
||||
renderSystemStatus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderSystemStatus()
|
||||
}
|
||||
|
||||
private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? {
|
||||
val validation = collectConfigFromForm().validate()
|
||||
val config = validation.normalized
|
||||
if (config == null) {
|
||||
binding.errorText.text = validation.errorMessage
|
||||
binding.errorText.isVisible = true
|
||||
return null
|
||||
}
|
||||
|
||||
binding.errorText.isVisible = false
|
||||
settingsStore.save(config)
|
||||
if (showMessage) {
|
||||
Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
if (config.checkUpdates) {
|
||||
refreshUpdateStatus(checkNow = true)
|
||||
} else {
|
||||
currentUpdateStatus = null
|
||||
renderUpdateStatus(null, false)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
private fun onStartClicked() {
|
||||
onSaveClicked(showMessage = false) ?: return
|
||||
ProxyForegroundService.start(this)
|
||||
Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun onRestartClicked() {
|
||||
onSaveClicked(showMessage = false) ?: return
|
||||
ProxyForegroundService.restart(this)
|
||||
Snackbar.make(binding.root, R.string.service_restart_requested, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun onOpenLogsClicked() {
|
||||
startActivity(Intent(this, LogViewerActivity::class.java))
|
||||
}
|
||||
|
||||
private fun onOpenTelegramClicked() {
|
||||
val config = onSaveClicked(showMessage = false) ?: return
|
||||
if (!TelegramProxyIntent.open(this, config)) {
|
||||
Snackbar.make(binding.root, R.string.telegram_not_found, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderConfig(config: ProxyConfig) {
|
||||
binding.hostInput.setText(config.host)
|
||||
binding.portInput.setText(config.portText)
|
||||
binding.secretInput.setText(config.secretText)
|
||||
binding.dcIpInput.setText(config.dcIpText)
|
||||
binding.logMaxMbInput.setText(config.logMaxMbText)
|
||||
binding.bufferKbInput.setText(config.bufferKbText)
|
||||
binding.poolSizeInput.setText(config.poolSizeText)
|
||||
binding.checkUpdatesSwitch.isChecked = config.checkUpdates
|
||||
binding.verboseSwitch.isChecked = config.verbose
|
||||
renderUpdateStatus(currentUpdateStatus, config.checkUpdates)
|
||||
}
|
||||
|
||||
private fun collectConfigFromForm(): ProxyConfig {
|
||||
return ProxyConfig(
|
||||
host = binding.hostInput.text?.toString().orEmpty(),
|
||||
portText = binding.portInput.text?.toString().orEmpty(),
|
||||
secretText = binding.secretInput.text?.toString().orEmpty(),
|
||||
dcIpText = binding.dcIpInput.text?.toString().orEmpty(),
|
||||
logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(),
|
||||
bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(),
|
||||
poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(),
|
||||
checkUpdates = binding.checkUpdatesSwitch.isChecked,
|
||||
verbose = binding.verboseSwitch.isChecked,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onOpenReleasePageClicked() {
|
||||
val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
|
||||
val opened = runCatching {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}.isSuccess
|
||||
if (!opened) {
|
||||
Snackbar.make(binding.root, R.string.release_page_open_failed, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshUpdateStatus(checkNow: Boolean) {
|
||||
lifecycleScope.launch {
|
||||
val status = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
PythonProxyBridge.getUpdateStatus(this@MainActivity, checkNow)
|
||||
}
|
||||
}.getOrElse { exc ->
|
||||
ProxyUpdateStatus(
|
||||
currentVersion = currentAppVersionName(),
|
||||
error = exc.message ?: exc.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
currentUpdateStatus = status
|
||||
renderUpdateStatus(status, binding.checkUpdatesSwitch.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderUpdateStatus(status: ProxyUpdateStatus?, checkUpdatesEnabled: Boolean) {
|
||||
val currentVersion = status?.currentVersion?.takeIf { it.isNotBlank() } ?: currentAppVersionName()
|
||||
binding.currentVersionValue.text = getString(
|
||||
R.string.updates_current_version_format,
|
||||
currentVersion,
|
||||
)
|
||||
binding.updateStatusValue.text = when {
|
||||
!checkUpdatesEnabled -> {
|
||||
getString(R.string.updates_status_disabled)
|
||||
}
|
||||
status == null -> {
|
||||
getString(R.string.updates_status_initial)
|
||||
}
|
||||
!status.error.isNullOrBlank() -> {
|
||||
getString(R.string.updates_status_error, status.error)
|
||||
}
|
||||
!status.checked -> {
|
||||
getString(R.string.updates_status_idle)
|
||||
}
|
||||
status.hasUpdate && !status.latestVersion.isNullOrBlank() -> {
|
||||
getString(
|
||||
R.string.updates_status_available,
|
||||
status.latestVersion,
|
||||
status.currentVersion,
|
||||
)
|
||||
}
|
||||
status.aheadOfRelease -> {
|
||||
getString(R.string.updates_status_newer, status.currentVersion)
|
||||
}
|
||||
else -> {
|
||||
getString(R.string.updates_status_latest, status.currentVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentAppVersionName(): String {
|
||||
return runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
}.getOrNull().orEmpty().ifBlank { "unknown" }
|
||||
}
|
||||
|
||||
private fun observeServiceState() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
combine(
|
||||
ProxyServiceState.isStarting,
|
||||
ProxyServiceState.isRunning,
|
||||
) { isStarting, isRunning ->
|
||||
isStarting to isRunning
|
||||
}.collect { (isStarting, isRunning) ->
|
||||
binding.statusValue.text = getString(
|
||||
when {
|
||||
isStarting -> R.string.status_starting
|
||||
isRunning -> R.string.status_running
|
||||
else -> R.string.status_stopped
|
||||
},
|
||||
)
|
||||
binding.startButton.isEnabled = !isStarting && !isRunning
|
||||
binding.stopButton.isEnabled = isStarting || isRunning
|
||||
binding.restartButton.isEnabled = !isStarting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
combine(
|
||||
ProxyServiceState.activeConfig,
|
||||
ProxyServiceState.isStarting,
|
||||
) { config, isStarting ->
|
||||
config to isStarting
|
||||
}.collect { (config, isStarting) ->
|
||||
binding.serviceHint.text = if (config == null) {
|
||||
getString(R.string.service_hint_idle)
|
||||
} else if (isStarting) {
|
||||
getString(
|
||||
R.string.service_hint_starting,
|
||||
config.host,
|
||||
config.port,
|
||||
)
|
||||
} else {
|
||||
getString(
|
||||
R.string.service_hint_running,
|
||||
config.host,
|
||||
config.port,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
ProxyServiceState.lastError.collect { error ->
|
||||
if (error.isNullOrBlank()) {
|
||||
binding.lastErrorCard.isVisible = false
|
||||
} else {
|
||||
binding.lastErrorValue.text = error
|
||||
binding.lastErrorCard.isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderSystemStatus() {
|
||||
val status = AndroidSystemStatus.read(this)
|
||||
|
||||
binding.systemStatusValue.text = getString(
|
||||
if (status.canKeepRunningReliably) {
|
||||
R.string.system_status_ready
|
||||
} else {
|
||||
R.string.system_status_attention
|
||||
},
|
||||
)
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
lines += if (status.ignoringBatteryOptimizations) {
|
||||
getString(R.string.system_check_battery_ignored)
|
||||
} else {
|
||||
getString(R.string.system_check_battery_active)
|
||||
}
|
||||
lines += if (status.backgroundRestricted) {
|
||||
getString(R.string.system_check_background_restricted)
|
||||
} else {
|
||||
getString(R.string.system_check_background_ok)
|
||||
}
|
||||
lines += getString(R.string.system_check_oem_note)
|
||||
binding.systemStatusHint.text = lines.joinToString("\n")
|
||||
|
||||
binding.disableBatteryOptimizationButton.isVisible = !status.ignoringBatteryOptimizations
|
||||
binding.openAppSettingsButton.isVisible = status.backgroundRestricted || !status.ignoringBatteryOptimizations
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
data class ProxyConfig(
|
||||
val host: String = DEFAULT_HOST,
|
||||
val portText: String = DEFAULT_PORT.toString(),
|
||||
val secretText: String = DEFAULT_SECRET,
|
||||
val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"),
|
||||
val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB),
|
||||
val bufferKbText: String = DEFAULT_BUFFER_KB.toString(),
|
||||
val poolSizeText: String = DEFAULT_POOL_SIZE.toString(),
|
||||
val checkUpdates: Boolean = false,
|
||||
val verbose: Boolean = false,
|
||||
) {
|
||||
fun validate(): ValidationResult {
|
||||
val hostValue = host.trim()
|
||||
if (!isIpv4Address(hostValue)) {
|
||||
return ValidationResult(errorMessage = "IP-адрес прокси указан некорректно.")
|
||||
}
|
||||
|
||||
val portValue = portText.trim().toIntOrNull()
|
||||
?: return ValidationResult(errorMessage = "Порт должен быть числом.")
|
||||
if (portValue !in 1..65535) {
|
||||
return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.")
|
||||
}
|
||||
|
||||
val secretValue = secretText.trim().lowercase()
|
||||
if (secretValue.length != 32 || !secretValue.all { it in "0123456789abcdef" }) {
|
||||
return ValidationResult(
|
||||
errorMessage = "MTProto secret должен содержать ровно 32 hex-символа."
|
||||
)
|
||||
}
|
||||
|
||||
val lines = dcIpText
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
if (lines.isEmpty()) {
|
||||
return ValidationResult(errorMessage = "Добавьте хотя бы один DC:IP маппинг.")
|
||||
}
|
||||
|
||||
for (line in lines) {
|
||||
val parts = line.split(":", limit = 2)
|
||||
val dcValue = parts.firstOrNull()?.toIntOrNull()
|
||||
val ipValue = parts.getOrNull(1)?.trim().orEmpty()
|
||||
if (parts.size != 2 || dcValue == null || !isIpv4Address(ipValue)) {
|
||||
return ValidationResult(errorMessage = "Строка \"$line\" должна быть в формате DC:IP.")
|
||||
}
|
||||
}
|
||||
|
||||
val logMaxMbValue = logMaxMbText.trim().toDoubleOrNull()
|
||||
?: return ValidationResult(
|
||||
errorMessage = "Размер лог-файла должен быть числом."
|
||||
)
|
||||
if (logMaxMbValue <= 0.0) {
|
||||
return ValidationResult(
|
||||
errorMessage = "Размер лог-файла должен быть больше нуля."
|
||||
)
|
||||
}
|
||||
|
||||
val bufferKbValue = bufferKbText.trim().toIntOrNull()
|
||||
?: return ValidationResult(
|
||||
errorMessage = "Буфер сокета должен быть целым числом."
|
||||
)
|
||||
if (bufferKbValue < 4) {
|
||||
return ValidationResult(
|
||||
errorMessage = "Буфер сокета должен быть не меньше 4 KB."
|
||||
)
|
||||
}
|
||||
|
||||
val poolSizeValue = poolSizeText.trim().toIntOrNull()
|
||||
?: return ValidationResult(
|
||||
errorMessage = "Размер WS pool должен быть целым числом."
|
||||
)
|
||||
if (poolSizeValue < 0) {
|
||||
return ValidationResult(
|
||||
errorMessage = "Размер WS pool не может быть отрицательным."
|
||||
)
|
||||
}
|
||||
|
||||
return ValidationResult(
|
||||
normalized = NormalizedProxyConfig(
|
||||
host = hostValue,
|
||||
port = portValue,
|
||||
secret = secretValue,
|
||||
dcIpList = lines,
|
||||
logMaxMb = logMaxMbValue,
|
||||
bufferKb = bufferKbValue,
|
||||
poolSize = poolSizeValue,
|
||||
checkUpdates = checkUpdates,
|
||||
verbose = verbose,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_HOST = "127.0.0.1"
|
||||
const val DEFAULT_PORT = 1443
|
||||
const val DEFAULT_LOG_MAX_MB = 5.0
|
||||
const val DEFAULT_BUFFER_KB = 256
|
||||
const val DEFAULT_POOL_SIZE = 4
|
||||
val DEFAULT_SECRET = generateSecret()
|
||||
val DEFAULT_DC_IP_LINES = listOf(
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220",
|
||||
)
|
||||
|
||||
fun formatDecimal(value: Double): String {
|
||||
return if (value % 1.0 == 0.0) {
|
||||
value.toInt().toString()
|
||||
} else {
|
||||
value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateSecret(): String {
|
||||
val bytes = ByteArray(16)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString(separator = "") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun isIpv4Address(value: String): Boolean {
|
||||
val octets = value.split(".")
|
||||
if (octets.size != 4) {
|
||||
return false
|
||||
}
|
||||
|
||||
return octets.all { octet ->
|
||||
octet.isNotEmpty() &&
|
||||
octet.length <= 3 &&
|
||||
octet.all(Char::isDigit) &&
|
||||
octet.toIntOrNull() in 0..255
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ValidationResult(
|
||||
val normalized: NormalizedProxyConfig? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class NormalizedProxyConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val secret: String,
|
||||
val dcIpList: List<String>,
|
||||
val logMaxMb: Double,
|
||||
val bufferKb: Int,
|
||||
val poolSize: Int,
|
||||
val checkUpdates: Boolean,
|
||||
val verbose: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,395 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
class ProxyForegroundService : Service() {
|
||||
private lateinit var settingsStore: ProxySettingsStore
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var trafficJob: Job? = null
|
||||
private var lastTrafficSample: TrafficSample? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settingsStore = ProxySettingsStore(this)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
ProxyServiceState.clearError()
|
||||
serviceScope.launch {
|
||||
stopProxyRuntime(removeNotification = true, stopService = true)
|
||||
}
|
||||
START_NOT_STICKY
|
||||
}
|
||||
|
||||
ACTION_RESTART -> {
|
||||
val config = loadValidatedConfig() ?: return START_NOT_STICKY
|
||||
ProxyServiceState.clearError()
|
||||
beginProxyStart(config)
|
||||
serviceScope.launch {
|
||||
stopRuntimeOnly()
|
||||
startProxyRuntime(config)
|
||||
}
|
||||
START_STICKY
|
||||
}
|
||||
|
||||
else -> {
|
||||
val config = loadValidatedConfig() ?: return START_NOT_STICKY
|
||||
beginProxyStart(config)
|
||||
serviceScope.launch {
|
||||
startProxyRuntime(config)
|
||||
}
|
||||
START_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopTrafficUpdates()
|
||||
serviceScope.cancel()
|
||||
runCatching { PythonProxyBridge.stop(this) }
|
||||
ProxyServiceState.markStopped()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun buildNotification(payload: NotificationPayload): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(payload.statusText)
|
||||
.setSubText(payload.endpointText)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(payload.detailsText),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_proxy_notification)
|
||||
.setContentIntent(createOpenAppPendingIntent())
|
||||
.addAction(
|
||||
0,
|
||||
getString(R.string.notification_action_stop),
|
||||
createStopPendingIntent(),
|
||||
)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun startProxyRuntime(config: NormalizedProxyConfig) {
|
||||
val result = runCatching {
|
||||
PythonProxyBridge.start(this, config)
|
||||
}
|
||||
|
||||
result.onSuccess {
|
||||
ProxyServiceState.markStarted(config)
|
||||
lastTrafficSample = null
|
||||
updateNotification(
|
||||
buildNotificationPayload(
|
||||
config = config,
|
||||
trafficState = TrafficState(running = true),
|
||||
statusText = getString(
|
||||
R.string.notification_running,
|
||||
config.host,
|
||||
config.port,
|
||||
),
|
||||
),
|
||||
)
|
||||
startTrafficUpdates(config)
|
||||
}.onFailure { error ->
|
||||
ProxyServiceState.markFailed(
|
||||
error.message ?: getString(R.string.proxy_start_failed_generic),
|
||||
)
|
||||
stopTrafficUpdates()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadValidatedConfig(): NormalizedProxyConfig? {
|
||||
val config = settingsStore.load().validate().normalized
|
||||
if (config == null) {
|
||||
ProxyServiceState.markFailed(getString(R.string.saved_config_invalid))
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
private fun beginProxyStart(config: NormalizedProxyConfig) {
|
||||
ProxyServiceState.markStarting(config)
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(
|
||||
buildNotificationPayload(
|
||||
config = config,
|
||||
trafficState = TrafficState(),
|
||||
statusText = getString(
|
||||
R.string.notification_starting,
|
||||
config.host,
|
||||
config.port,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
|
||||
stopRuntimeOnly()
|
||||
ProxyServiceState.markStopped()
|
||||
|
||||
if (removeNotification) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
if (stopService) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRuntimeOnly() {
|
||||
stopTrafficUpdates()
|
||||
runCatching { PythonProxyBridge.stop(this) }
|
||||
}
|
||||
|
||||
private fun updateNotification(payload: NotificationPayload) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, buildNotification(payload))
|
||||
}
|
||||
|
||||
private fun buildNotificationPayload(
|
||||
config: NormalizedProxyConfig,
|
||||
trafficState: TrafficState,
|
||||
statusText: String,
|
||||
): NotificationPayload {
|
||||
val endpointText = getString(R.string.notification_endpoint, config.host, config.port)
|
||||
val detailsText = getString(
|
||||
R.string.notification_details,
|
||||
config.dcIpList.size,
|
||||
formatRate(trafficState.upBytesPerSecond),
|
||||
formatRate(trafficState.downBytesPerSecond),
|
||||
formatBytes(trafficState.totalBytesUp),
|
||||
formatBytes(trafficState.totalBytesDown),
|
||||
)
|
||||
return NotificationPayload(
|
||||
statusText = statusText,
|
||||
endpointText = endpointText,
|
||||
detailsText = detailsText,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startTrafficUpdates(config: NormalizedProxyConfig) {
|
||||
stopTrafficUpdates()
|
||||
trafficJob = serviceScope.launch {
|
||||
while (isActive && ProxyServiceState.isRunning.value) {
|
||||
val trafficState = readTrafficState()
|
||||
if (!trafficState.running) {
|
||||
ProxyServiceState.markFailed(
|
||||
trafficState.lastError ?: getString(R.string.proxy_runtime_stopped_unexpectedly),
|
||||
)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
break
|
||||
}
|
||||
updateNotification(
|
||||
buildNotificationPayload(
|
||||
config = config,
|
||||
trafficState = trafficState,
|
||||
statusText = getString(
|
||||
R.string.notification_running,
|
||||
config.host,
|
||||
config.port,
|
||||
),
|
||||
),
|
||||
)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTrafficUpdates() {
|
||||
trafficJob?.cancel()
|
||||
trafficJob = null
|
||||
lastTrafficSample = null
|
||||
}
|
||||
|
||||
private fun readTrafficState(): TrafficState {
|
||||
val nowMillis = System.currentTimeMillis()
|
||||
val current = PythonProxyBridge.getTrafficStats(this)
|
||||
val previous = lastTrafficSample
|
||||
lastTrafficSample = TrafficSample(
|
||||
bytesUp = current.bytesUp,
|
||||
bytesDown = current.bytesDown,
|
||||
timestampMillis = nowMillis,
|
||||
)
|
||||
|
||||
if (!current.running || previous == null) {
|
||||
return TrafficState(
|
||||
upBytesPerSecond = 0L,
|
||||
downBytesPerSecond = 0L,
|
||||
totalBytesUp = current.bytesUp,
|
||||
totalBytesDown = current.bytesDown,
|
||||
running = current.running,
|
||||
lastError = current.lastError,
|
||||
)
|
||||
}
|
||||
|
||||
val elapsedMillis = (nowMillis - previous.timestampMillis).coerceAtLeast(1L)
|
||||
val upDelta = (current.bytesUp - previous.bytesUp).coerceAtLeast(0L)
|
||||
val downDelta = (current.bytesDown - previous.bytesDown).coerceAtLeast(0L)
|
||||
return TrafficState(
|
||||
upBytesPerSecond = (upDelta * 1000L) / elapsedMillis,
|
||||
downBytesPerSecond = (downDelta * 1000L) / elapsedMillis,
|
||||
totalBytesUp = current.bytesUp,
|
||||
totalBytesDown = current.bytesDown,
|
||||
running = current.running,
|
||||
lastError = current.lastError,
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatRate(bytesPerSecond: Long): String = formatBytes(bytesPerSecond)
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
val units = arrayOf("B", "KB", "MB", "GB")
|
||||
var value = bytes.toDouble().coerceAtLeast(0.0)
|
||||
var unitIndex = 0
|
||||
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
value /= 1024.0
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return if (unitIndex == 0) {
|
||||
String.format(Locale.US, "%.0f %s", value, units[unitIndex])
|
||||
} else {
|
||||
String.format(Locale.US, "%.1f %s", value, units[unitIndex])
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOpenAppPendingIntent(): PendingIntent {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
)
|
||||
}
|
||||
?: Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
)
|
||||
}
|
||||
|
||||
return TaskStackBuilder.create(this)
|
||||
.addNextIntentWithParentStack(launchIntent)
|
||||
.getPendingIntent(
|
||||
1,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
?: PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createStopPendingIntent(): PendingIntent {
|
||||
val intent = Intent(this, ProxyForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
return PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = getString(R.string.notification_channel_description)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "proxy_service"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val ACTION_START = "org.flowseal.tgwsproxy.action.START"
|
||||
private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP"
|
||||
private const val ACTION_RESTART = "org.flowseal.tgwsproxy.action.RESTART"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, ProxyForegroundService::class.java).apply {
|
||||
action = ACTION_START
|
||||
}
|
||||
androidx.core.content.ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, ProxyForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
val intent = Intent(context, ProxyForegroundService::class.java).apply {
|
||||
action = ACTION_RESTART
|
||||
}
|
||||
androidx.core.content.ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class NotificationPayload(
|
||||
val statusText: String,
|
||||
val endpointText: String,
|
||||
val detailsText: String,
|
||||
)
|
||||
|
||||
private data class TrafficSample(
|
||||
val bytesUp: Long,
|
||||
val bytesDown: Long,
|
||||
val timestampMillis: Long,
|
||||
)
|
||||
|
||||
private data class TrafficState(
|
||||
val upBytesPerSecond: Long = 0L,
|
||||
val downBytesPerSecond: Long = 0L,
|
||||
val totalBytesUp: Long = 0L,
|
||||
val totalBytesDown: Long = 0L,
|
||||
val running: Boolean = false,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
object ProxyServiceState {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning
|
||||
|
||||
private val _isStarting = MutableStateFlow(false)
|
||||
val isStarting: StateFlow<Boolean> = _isStarting
|
||||
|
||||
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
|
||||
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig
|
||||
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError
|
||||
|
||||
fun markStarting(config: NormalizedProxyConfig) {
|
||||
_activeConfig.value = config
|
||||
_isStarting.value = true
|
||||
_isRunning.value = false
|
||||
_lastError.value = null
|
||||
}
|
||||
|
||||
fun markStarted(config: NormalizedProxyConfig) {
|
||||
_activeConfig.value = config
|
||||
_isStarting.value = false
|
||||
_isRunning.value = true
|
||||
_lastError.value = null
|
||||
}
|
||||
|
||||
fun markFailed(message: String) {
|
||||
_activeConfig.value = null
|
||||
_isStarting.value = false
|
||||
_isRunning.value = false
|
||||
_lastError.value = message
|
||||
}
|
||||
|
||||
fun markStopped() {
|
||||
_activeConfig.value = null
|
||||
_isStarting.value = false
|
||||
_isRunning.value = false
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_lastError.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class ProxySettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun load(): ProxyConfig {
|
||||
return ProxyConfig(
|
||||
host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(),
|
||||
portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(),
|
||||
secretText = preferences.getString(KEY_SECRET, ProxyConfig.DEFAULT_SECRET).orEmpty(),
|
||||
dcIpText = preferences.getString(
|
||||
KEY_DC_IP_TEXT,
|
||||
ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"),
|
||||
).orEmpty(),
|
||||
logMaxMbText = ProxyConfig.formatDecimal(
|
||||
preferences.getFloat(
|
||||
KEY_LOG_MAX_MB,
|
||||
ProxyConfig.DEFAULT_LOG_MAX_MB.toFloat(),
|
||||
).toDouble()
|
||||
),
|
||||
bufferKbText = preferences.getInt(
|
||||
KEY_BUFFER_KB,
|
||||
ProxyConfig.DEFAULT_BUFFER_KB,
|
||||
).toString(),
|
||||
poolSizeText = preferences.getInt(
|
||||
KEY_POOL_SIZE,
|
||||
ProxyConfig.DEFAULT_POOL_SIZE,
|
||||
).toString(),
|
||||
checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, false),
|
||||
verbose = preferences.getBoolean(KEY_VERBOSE, false),
|
||||
)
|
||||
}
|
||||
|
||||
fun save(config: NormalizedProxyConfig) {
|
||||
preferences.edit()
|
||||
.putString(KEY_HOST, config.host)
|
||||
.putInt(KEY_PORT, config.port)
|
||||
.putString(KEY_SECRET, config.secret)
|
||||
.putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n"))
|
||||
.putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat())
|
||||
.putInt(KEY_BUFFER_KB, config.bufferKb)
|
||||
.putInt(KEY_POOL_SIZE, config.poolSize)
|
||||
.putBoolean(KEY_CHECK_UPDATES, config.checkUpdates)
|
||||
.putBoolean(KEY_VERBOSE, config.verbose)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "proxy_settings"
|
||||
private const val KEY_HOST = "host"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_SECRET = "secret"
|
||||
private const val KEY_DC_IP_TEXT = "dc_ip_text"
|
||||
private const val KEY_LOG_MAX_MB = "log_max_mb"
|
||||
private const val KEY_BUFFER_KB = "buf_kb"
|
||||
private const val KEY_POOL_SIZE = "pool_size"
|
||||
private const val KEY_CHECK_UPDATES = "check_updates"
|
||||
private const val KEY_VERBOSE = "verbose"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.content.Context
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
object PythonProxyBridge {
|
||||
private const val MODULE_NAME = "android_proxy_bridge"
|
||||
private val pythonStartLock = Any()
|
||||
|
||||
fun start(context: Context, config: NormalizedProxyConfig): String {
|
||||
val module = getModule(context)
|
||||
return module.callAttr(
|
||||
"start_proxy",
|
||||
File(context.filesDir, "tg-ws-proxy").absolutePath,
|
||||
config.host,
|
||||
config.port,
|
||||
config.secret,
|
||||
config.dcIpList,
|
||||
config.logMaxMb,
|
||||
config.bufferKb,
|
||||
config.poolSize,
|
||||
config.verbose,
|
||||
).toString()
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
if (!Python.isStarted()) {
|
||||
return
|
||||
}
|
||||
getModule(context).callAttr("stop_proxy")
|
||||
}
|
||||
|
||||
fun getTrafficStats(context: Context): ProxyTrafficStats {
|
||||
if (!Python.isStarted()) {
|
||||
return ProxyTrafficStats()
|
||||
}
|
||||
|
||||
val payload = getModule(context).callAttr("get_runtime_stats_json").toString()
|
||||
val json = JSONObject(payload)
|
||||
return ProxyTrafficStats(
|
||||
bytesUp = json.optLong("bytes_up", 0L),
|
||||
bytesDown = json.optLong("bytes_down", 0L),
|
||||
running = json.optBoolean("running", false),
|
||||
lastError = json.optString("last_error").ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
fun getUpdateStatus(context: Context, checkNow: Boolean = false): ProxyUpdateStatus {
|
||||
val payload = getModule(context).callAttr("get_update_status_json", checkNow).toString()
|
||||
val json = JSONObject(payload)
|
||||
return ProxyUpdateStatus(
|
||||
currentVersion = json.optString("current_version").ifBlank { "unknown" },
|
||||
latestVersion = json.optString("latest").ifBlank { null },
|
||||
hasUpdate = json.optBoolean("has_update", false),
|
||||
aheadOfRelease = json.optBoolean("ahead_of_release", false),
|
||||
checked = json.optBoolean("checked", false),
|
||||
htmlUrl = json.optString("html_url").ifBlank { null },
|
||||
error = json.optString("error").ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun getModule(context: Context) =
|
||||
getPython(context.applicationContext).getModule(MODULE_NAME)
|
||||
|
||||
private fun getPython(context: Context): Python {
|
||||
if (Python.isStarted()) {
|
||||
return Python.getInstance()
|
||||
}
|
||||
synchronized(pythonStartLock) {
|
||||
if (!Python.isStarted()) {
|
||||
try {
|
||||
Python.start(AndroidPlatform(context))
|
||||
} catch (exc: IllegalStateException) {
|
||||
if (!Python.isStarted()) {
|
||||
throw exc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Python.getInstance()
|
||||
}
|
||||
}
|
||||
|
||||
data class ProxyTrafficStats(
|
||||
val bytesUp: Long = 0L,
|
||||
val bytesDown: Long = 0L,
|
||||
val running: Boolean = false,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
data class ProxyUpdateStatus(
|
||||
val currentVersion: String = "unknown",
|
||||
val latestVersion: String? = null,
|
||||
val hasUpdate: Boolean = false,
|
||||
val aheadOfRelease: Boolean = false,
|
||||
val checked: Boolean = false,
|
||||
val htmlUrl: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.flowseal.tgwsproxy
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
object TelegramProxyIntent {
|
||||
fun open(context: Context, config: NormalizedProxyConfig): Boolean {
|
||||
val uri = Uri.parse(
|
||||
"tg://proxy?server=${Uri.encode(config.host)}&port=${config.port}&secret=dd${Uri.encode(config.secret)}"
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
return try {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from proxy.app_runtime import ProxyAppRuntime
|
||||
from proxy import __version__
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
|
||||
|
||||
RELEASES_PAGE_URL = "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
|
||||
|
||||
|
||||
_RUNTIME_LOCK = threading.RLock()
|
||||
_RUNTIME: Optional[ProxyAppRuntime] = None
|
||||
_LAST_ERROR: Optional[str] = None
|
||||
|
||||
|
||||
def _remember_error(message: str) -> None:
|
||||
global _LAST_ERROR
|
||||
_LAST_ERROR = message
|
||||
|
||||
|
||||
def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]:
|
||||
if dc_ip_list is None:
|
||||
return []
|
||||
|
||||
values: list[object]
|
||||
try:
|
||||
values = list(dc_ip_list)
|
||||
except TypeError:
|
||||
# Chaquopy may expose Kotlin's List<String> as java.util.ArrayList,
|
||||
# which isn't always directly iterable from Python.
|
||||
if hasattr(dc_ip_list, "toArray"):
|
||||
values = list(dc_ip_list.toArray())
|
||||
elif hasattr(dc_ip_list, "size") and hasattr(dc_ip_list, "get"):
|
||||
size = int(dc_ip_list.size())
|
||||
values = [dc_ip_list.get(i) for i in range(size)]
|
||||
else:
|
||||
values = [dc_ip_list]
|
||||
|
||||
return [str(item).strip() for item in values if str(item).strip()]
|
||||
|
||||
|
||||
def start_proxy(app_dir: str, host: str, port: int, secret: str,
|
||||
dc_ip_list: Iterable[object], log_max_mb: float = 5.0,
|
||||
buf_kb: int = 256, pool_size: int = 4,
|
||||
verbose: bool = False) -> str:
|
||||
global _RUNTIME, _LAST_ERROR
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
if _RUNTIME is not None:
|
||||
_RUNTIME.stop_proxy()
|
||||
_RUNTIME = None
|
||||
|
||||
_LAST_ERROR = None
|
||||
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
|
||||
tg_ws_proxy.reset_stats()
|
||||
|
||||
runtime = ProxyAppRuntime(
|
||||
Path(app_dir),
|
||||
logger_name="tg-ws-android",
|
||||
on_error=_remember_error,
|
||||
)
|
||||
runtime.reset_log_file()
|
||||
runtime.setup_logging(verbose=verbose, log_max_mb=float(log_max_mb))
|
||||
|
||||
config = {
|
||||
"host": host,
|
||||
"port": int(port),
|
||||
"secret": str(secret).strip(),
|
||||
"dc_ip": _normalize_dc_ip_list(dc_ip_list),
|
||||
"log_max_mb": float(log_max_mb),
|
||||
"buf_kb": int(buf_kb),
|
||||
"pool_size": int(pool_size),
|
||||
"verbose": bool(verbose),
|
||||
}
|
||||
runtime.save_config(config)
|
||||
|
||||
if not runtime.start_proxy(config):
|
||||
_RUNTIME = None
|
||||
raise RuntimeError(_LAST_ERROR or "Failed to start proxy runtime.")
|
||||
|
||||
_RUNTIME = runtime
|
||||
|
||||
# Give the proxy thread a short warm-up window so immediate bind failures
|
||||
# surface before Kotlin reports the service as running.
|
||||
for _ in range(10):
|
||||
time.sleep(0.1)
|
||||
with _RUNTIME_LOCK:
|
||||
if _LAST_ERROR:
|
||||
runtime.stop_proxy()
|
||||
_RUNTIME = None
|
||||
raise RuntimeError(_LAST_ERROR)
|
||||
if runtime.is_proxy_running():
|
||||
return str(runtime.log_file)
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
runtime.stop_proxy()
|
||||
_RUNTIME = None
|
||||
raise RuntimeError("Proxy runtime did not become ready in time.")
|
||||
|
||||
|
||||
def stop_proxy() -> None:
|
||||
global _RUNTIME, _LAST_ERROR
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
_LAST_ERROR = None
|
||||
if _RUNTIME is not None:
|
||||
_RUNTIME.stop_proxy()
|
||||
_RUNTIME = None
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
with _RUNTIME_LOCK:
|
||||
return bool(_RUNTIME and _RUNTIME.is_proxy_running())
|
||||
|
||||
|
||||
def get_last_error() -> Optional[str]:
|
||||
return _LAST_ERROR
|
||||
|
||||
|
||||
def get_runtime_stats_json() -> str:
|
||||
with _RUNTIME_LOCK:
|
||||
running = bool(_RUNTIME and _RUNTIME.is_proxy_running())
|
||||
|
||||
payload = dict(tg_ws_proxy.get_stats_snapshot())
|
||||
payload["running"] = running
|
||||
payload["last_error"] = _LAST_ERROR
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
def _load_update_check():
|
||||
from utils import update_check
|
||||
return update_check
|
||||
|
||||
|
||||
def get_update_status_json(check_now: bool = False) -> str:
|
||||
payload = {
|
||||
"current_version": __version__,
|
||||
"latest": "",
|
||||
"has_update": False,
|
||||
"ahead_of_release": False,
|
||||
"checked": False,
|
||||
"html_url": RELEASES_PAGE_URL,
|
||||
"error": "",
|
||||
}
|
||||
try:
|
||||
update_check = _load_update_check()
|
||||
if check_now:
|
||||
update_check.run_check(__version__)
|
||||
payload.update(update_check.get_status())
|
||||
payload["current_version"] = __version__
|
||||
payload["latest"] = payload.get("latest") or ""
|
||||
payload["html_url"] = payload.get("html_url") or update_check.RELEASES_PAGE_URL
|
||||
payload["error"] = payload.get("error") or ""
|
||||
except Exception as exc:
|
||||
payload["error"] = str(exc)
|
||||
return json.dumps(payload)
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#1E88E5"
|
||||
android:pathData="M54,10A44,44 0 1,1 10,54A44,44 0 0,1 54,10Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M33,34h42v10H59v30H49V44H33z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,4h14v3h-5v12h-4V7H5z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,20h8v2H8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/logs_title"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/logs_subtitle"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/logs_path_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/logPathValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/logContentValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textIsSelectable="true" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/refreshLogsButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/refresh_logs_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/closeLogsButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/close_logs_button" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,424 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/subtitle"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/status_stopped"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/serviceHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="@string/service_hint_idle"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:id="@+id/lastErrorCard"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/last_error_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorError" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lastErrorValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorError" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/updates_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentVersionValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/checkUpdatesSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/updates_check_label" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/updateStatusValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/updates_status_initial"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openReleasePageButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/updates_open_release_button" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/system_status_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/systemStatusValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="@string/system_status_attention"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/systemStatusHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/disableBatteryOptimizationButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/disable_battery_optimization_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openAppSettingsButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/open_app_settings_button" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/endpoint_section_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:hint="@string/host_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/hostInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/port_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/portInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/secret_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/secretInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/dc_ip_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/dcIpInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="140dp"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine"
|
||||
android:minLines="5" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/advanced_section_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/verboseSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/verbose_label" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/log_max_mb_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/logMaxMbInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/buffer_kb_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/bufferKbInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/pool_size_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/poolSizeInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
app:cardCornerRadius="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/actions_section_label"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/saveButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/save_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/startButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/start_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/stopButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/stop_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/restartButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/restart_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openTelegramButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/open_telegram_button" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openLogsButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/open_logs_button" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="proxy_blue">#1E88E5</color>
|
||||
<color name="proxy_navy">#0B1F33</color>
|
||||
<color name="proxy_surface">#F4F8FC</color>
|
||||
<color name="proxy_white">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TG WS Proxy</string>
|
||||
<string name="subtitle">Android app for the local Telegram MTProto proxy.</string>
|
||||
<string name="status_label">Foreground service</string>
|
||||
<string name="status_starting">Starting</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="status_stopped">Stopped</string>
|
||||
<string name="service_hint_idle">Configure the proxy settings, then start the foreground service.</string>
|
||||
<string name="service_hint_starting">Starting embedded Python proxy for %1$s:%2$d.</string>
|
||||
<string name="service_hint_running">Foreground service active for %1$s:%2$d.</string>
|
||||
<string name="system_status_label">Android background limits</string>
|
||||
<string name="system_status_ready">Ready</string>
|
||||
<string name="system_status_attention">Needs attention</string>
|
||||
<string name="system_check_battery_ignored">Battery optimization: disabled for this app.</string>
|
||||
<string name="system_check_battery_active">Battery optimization: still enabled, Android may stop the proxy in background.</string>
|
||||
<string name="system_check_background_ok">Background restriction: not detected.</string>
|
||||
<string name="system_check_background_restricted">Background restriction: enabled, Android may block long-running work.</string>
|
||||
<string name="system_check_oem_note">Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode.</string>
|
||||
<string name="endpoint_section_label">Proxy endpoint</string>
|
||||
<string name="advanced_section_label">Advanced</string>
|
||||
<string name="actions_section_label">Actions</string>
|
||||
<string name="updates_label">Updates</string>
|
||||
<string name="updates_current_version_format">v%1$s</string>
|
||||
<string name="updates_check_label">Check for updates on launch</string>
|
||||
<string name="updates_status_initial">Checking GitHub release…</string>
|
||||
<string name="updates_status_idle">Not checked yet</string>
|
||||
<string name="updates_status_disabled">Automatic update checks are disabled.</string>
|
||||
<string name="updates_status_latest">Installed version %1$s matches the latest GitHub release.</string>
|
||||
<string name="updates_status_newer">Installed version %1$s is newer than the latest GitHub release.</string>
|
||||
<string name="updates_status_available">Version %1$s is available on GitHub (installed: %2$s).</string>
|
||||
<string name="updates_status_error">Update check failed: %1$s</string>
|
||||
<string name="updates_open_release_button">Open Release Page</string>
|
||||
<string name="host_hint">Proxy host</string>
|
||||
<string name="port_hint">Proxy port</string>
|
||||
<string name="secret_hint">MTProto secret (32 hex characters)</string>
|
||||
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
|
||||
<string name="log_max_mb_hint">Max log size before rotation (MB)</string>
|
||||
<string name="buffer_kb_hint">Socket buffer size (KB)</string>
|
||||
<string name="pool_size_hint">WS pool size per DC</string>
|
||||
<string name="verbose_label">Verbose logging</string>
|
||||
<string name="save_button">Save Settings</string>
|
||||
<string name="start_button">Start Service</string>
|
||||
<string name="stop_button">Stop Service</string>
|
||||
<string name="restart_button">Restart Proxy</string>
|
||||
<string name="open_telegram_button">Open in Telegram</string>
|
||||
<string name="open_logs_button">Open Logs</string>
|
||||
<string name="disable_battery_optimization_button">Disable Battery Optimization</string>
|
||||
<string name="open_app_settings_button">Open App Settings</string>
|
||||
<string name="last_error_label">Last service error</string>
|
||||
<string name="release_page_open_failed">Failed to open release page.</string>
|
||||
<string name="settings_saved">Settings saved</string>
|
||||
<string name="service_start_requested">Foreground service start requested</string>
|
||||
<string name="service_restart_requested">Foreground service restart requested</string>
|
||||
<string name="telegram_not_found">Telegram app was not found for tg://proxy.</string>
|
||||
<string name="notification_title">TG WS Proxy</string>
|
||||
<string name="notification_channel_name">Proxy service</string>
|
||||
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string>
|
||||
<string name="notification_starting">MTProto %1$s:%2$d • starting embedded Python</string>
|
||||
<string name="notification_running">MTProto %1$s:%2$d • proxy active</string>
|
||||
<string name="notification_endpoint">%1$s:%2$d</string>
|
||||
<string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
|
||||
<string name="notification_action_stop">Stop</string>
|
||||
<string name="saved_config_invalid">Saved proxy settings are invalid.</string>
|
||||
<string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>
|
||||
<string name="proxy_runtime_stopped_unexpectedly">Proxy runtime stopped unexpectedly.</string>
|
||||
<string name="logs_title">Proxy Logs</string>
|
||||
<string name="logs_subtitle">Shows the latest lines from the embedded Python proxy log.</string>
|
||||
<string name="logs_path_label">Log file</string>
|
||||
<string name="refresh_logs_button">Refresh Logs</string>
|
||||
<string name="close_logs_button">Close</string>
|
||||
<string name="logs_empty">The log file is empty or has not been created yet.</string>
|
||||
<string name="logs_read_failed">Failed to read log file: %1$s</string>
|
||||
<string name="logs_truncated_prefix">Showing the last part of the log file.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.TgWsProxy" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/proxy_blue</item>
|
||||
<item name="colorSecondary">@color/proxy_navy</item>
|
||||
<item name="android:statusBarColor">@color/proxy_navy</item>
|
||||
<item name="android:navigationBarColor">@color/proxy_white</item>
|
||||
<item name="colorSurface">@color/proxy_surface</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_BUILD_DIR="$ROOT_DIR/app/build"
|
||||
|
||||
if [[ -z "${GRADLE_USER_HOME:-}" ]]; then
|
||||
if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then
|
||||
export GRADLE_USER_HOME="$HOME/.gradle"
|
||||
else
|
||||
export GRADLE_USER_HOME="$ROOT_DIR/.gradle-local"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$GRADLE_USER_HOME"
|
||||
|
||||
if [[ -d "$HOME/.local/jdk" ]]; then
|
||||
export JAVA_HOME="$HOME/.local/jdk"
|
||||
fi
|
||||
|
||||
if [[ -d "$HOME/android-sdk" ]]; then
|
||||
export ANDROID_SDK_ROOT="$HOME/android-sdk"
|
||||
fi
|
||||
|
||||
if [[ -n "${JAVA_HOME:-}" ]]; then
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
fi
|
||||
|
||||
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
|
||||
export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH"
|
||||
fi
|
||||
|
||||
if [[ -d "$HOME/.local/gradle/gradle-8.7/bin" ]]; then
|
||||
export PATH="$HOME/.local/gradle/gradle-8.7/bin:$PATH"
|
||||
fi
|
||||
|
||||
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy
|
||||
|
||||
GRADLE_BIN="gradle"
|
||||
if [[ -x "$ROOT_DIR/gradlew" ]]; then
|
||||
GRADLE_BIN="$ROOT_DIR/gradlew"
|
||||
fi
|
||||
GRADLE_RUN_DIR="$ROOT_DIR"
|
||||
|
||||
ATTEMPTS="${ATTEMPTS:-5}"
|
||||
SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
|
||||
TASK="${1:-assembleStandardDebug}"
|
||||
LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}"
|
||||
CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}"
|
||||
|
||||
running_on_wsl_windows_mount() {
|
||||
[[ -n "${WSL_DISTRO_NAME:-}" && "$ROOT_DIR" == /mnt/* ]]
|
||||
}
|
||||
|
||||
prepare_wsl_build_dir() {
|
||||
if ! running_on_wsl_windows_mount; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/tg-ws-proxy-android-build"
|
||||
local project_key
|
||||
project_key="$(printf '%s' "$ROOT_DIR" | sha256sum | cut -d' ' -f1)"
|
||||
local linux_build_dir="$cache_root/$project_key/app-build"
|
||||
|
||||
mkdir -p "$cache_root"
|
||||
mkdir -p "$linux_build_dir"
|
||||
|
||||
if [[ -L "$APP_BUILD_DIR" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -e "$APP_BUILD_DIR" ]]; then
|
||||
rm -rf "$APP_BUILD_DIR"
|
||||
fi
|
||||
|
||||
ln -s "$linux_build_dir" "$APP_BUILD_DIR"
|
||||
}
|
||||
|
||||
task_uses_legacy32() {
|
||||
[[ "$TASK" =~ [Ll]egacy32 ]]
|
||||
}
|
||||
|
||||
task_uses_standard() {
|
||||
if [[ "$TASK" =~ [Ss]tandard ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if task_uses_legacy32; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
prefetch_artifact() {
|
||||
local relative_path="$1"
|
||||
local destination="$LOCAL_CHAQUOPY_REPO/$relative_path"
|
||||
|
||||
if [[ -f "$destination" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
echo "Prefetching $relative_path"
|
||||
curl \
|
||||
--fail \
|
||||
--location \
|
||||
--retry 8 \
|
||||
--retry-all-errors \
|
||||
--continue-at - \
|
||||
--connect-timeout 15 \
|
||||
--speed-limit 1024 \
|
||||
--speed-time 20 \
|
||||
--max-time 90 \
|
||||
--output "$destination" \
|
||||
"$CHAQUOPY_MAVEN_BASE/$relative_path"
|
||||
}
|
||||
|
||||
prefetch_chaquopy_runtime() {
|
||||
local artifacts=(
|
||||
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.pom"
|
||||
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.jar"
|
||||
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0.pom"
|
||||
)
|
||||
|
||||
if task_uses_standard; then
|
||||
artifacts+=(
|
||||
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-arm64-v8a.so"
|
||||
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-x86_64.so"
|
||||
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0.pom"
|
||||
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-arm64-v8a.zip"
|
||||
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib-pyc.zip"
|
||||
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip"
|
||||
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip"
|
||||
)
|
||||
fi
|
||||
|
||||
if task_uses_legacy32; then
|
||||
artifacts+=(
|
||||
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.11-armeabi-v7a.so"
|
||||
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0.pom"
|
||||
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-armeabi-v7a.zip"
|
||||
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib-pyc.zip"
|
||||
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib.zip"
|
||||
)
|
||||
fi
|
||||
|
||||
for artifact in "${artifacts[@]}"; do
|
||||
prefetch_artifact "$artifact"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_stale_build_state() {
|
||||
local stale_dirs=(
|
||||
"$APP_BUILD_DIR/python/env"
|
||||
"$APP_BUILD_DIR/intermediates/project_dex_archive"
|
||||
"$APP_BUILD_DIR/intermediates/desugar_graph"
|
||||
"$APP_BUILD_DIR/tmp/kotlin-classes"
|
||||
"$APP_BUILD_DIR/snapshot/kotlin"
|
||||
)
|
||||
|
||||
for stale_dir in "${stale_dirs[@]}"; do
|
||||
if [[ -d "$stale_dir" ]]; then
|
||||
rm -rf "$stale_dir"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
prefetch_chaquopy_runtime
|
||||
prepare_wsl_build_dir
|
||||
|
||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||
echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)"
|
||||
if (
|
||||
cd "$GRADLE_RUN_DIR"
|
||||
"$GRADLE_BIN" --no-daemon --console=plain "$TASK"
|
||||
); then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$attempt" -lt "$ATTEMPTS" ]]; then
|
||||
cleanup_stale_build_state
|
||||
echo "Build failed, retrying in ${SLEEP_SECONDS}s..."
|
||||
sleep "$SLEEP_SECONDS"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Android build failed after $ATTEMPTS attempts."
|
||||
exit 1
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
systemProp.org.gradle.internal.http.connectionTimeout=120000
|
||||
systemProp.org.gradle.internal.http.socketTimeout=120000
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=120000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+249
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,28 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
|
||||
if (localChaquopyRepo.isDirectory) {
|
||||
maven(url = localChaquopyRepo.toURI())
|
||||
}
|
||||
maven("https://chaquo.com/maven")
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
|
||||
if (localChaquopyRepo.isDirectory) {
|
||||
maven(url = localChaquopyRepo.toURI())
|
||||
}
|
||||
maven("https://chaquo.com/maven")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "tg-ws-proxy-android"
|
||||
include(":app")
|
||||
@@ -1,75 +0,0 @@
|
||||
# Установка из исходников
|
||||
|
||||
## Консольный прокси
|
||||
|
||||
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy
|
||||
```
|
||||
|
||||
## Tray-приложение по ОС
|
||||
|
||||
### Windows 7/10+
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-macos
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-linux
|
||||
```
|
||||
|
||||
## Консольный режим из исходников
|
||||
|
||||
```bash
|
||||
tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
```
|
||||
|
||||
**Аргументы:**
|
||||
|
||||
| Аргумент | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `--port` | `1443` | Порт прокси |
|
||||
| `--host` | `127.0.0.1` | Хост прокси |
|
||||
| `--secret` | `random` | 32-значный hex-ключ для авторизации клиентов |
|
||||
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (параметр можно указывать несколько раз) |
|
||||
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
|
||||
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
|
||||
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
|
||||
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
|
||||
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||
|
||||
**Примеры:**
|
||||
|
||||
```bash
|
||||
# Стандартный запуск
|
||||
tg-ws-proxy
|
||||
|
||||
# Другой порт и дополнительные DC
|
||||
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
|
||||
|
||||
# С подробным логированием
|
||||
tg-ws-proxy -v
|
||||
|
||||
# Fake TLS маскировка (ee-secret)
|
||||
tg-ws-proxy --fake-tls-domain example.com
|
||||
```
|
||||
@@ -1,48 +0,0 @@
|
||||
# CONTRIBUTING
|
||||
|
||||
Спасибо за желание помочь проекту `tg-ws-proxy`.
|
||||
|
||||
## Перед созданием issue
|
||||
|
||||
1. Проверьте документацию в `docs/README.md`.
|
||||
2. Убедитесь, что похожий issue еще не открыт.
|
||||
3. Для корректной работы triage используйте стандартные лейблы из `.github/labels.md`.
|
||||
|
||||
## Как сообщать о проблемах
|
||||
|
||||
- Используйте шаблон `Проблема`.
|
||||
- По возможности укажите:
|
||||
- версию приложения,
|
||||
- ОС,
|
||||
- шаги воспроизведения,
|
||||
- ожидаемое и фактическое поведение,
|
||||
- лог-файл или текст ошибки.
|
||||
|
||||
Чем точнее описание, тем быстрее можно помочь.
|
||||
|
||||
## Локальный запуск из исходников
|
||||
|
||||
Требуется Python `>=3.8`.
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Запуск:
|
||||
|
||||
- консольный режим: `tg-ws-proxy`
|
||||
- Windows tray: `tg-ws-proxy-tray-win`
|
||||
- macOS tray: `tg-ws-proxy-tray-macos`
|
||||
- Linux tray: `tg-ws-proxy-tray-linux`
|
||||
|
||||
Подробности: `docs/BuildFromSource.md`.
|
||||
|
||||
## Pull Request
|
||||
|
||||
Перед открытием PR:
|
||||
|
||||
1. Убедитесь, что изменение решает конкретную проблему.
|
||||
2. Проверьте, что не сломаны существующие сценарии.
|
||||
3. Обновите документацию, если меняется поведение или настройка.
|
||||
|
||||
Небольшие и сфокусированные PR проверяются и принимаются быстрее.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Cloudflare-прокси
|
||||
|
||||
Для недоступных дата-центров можно использовать альтернативный бесплатный способ подключения — проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и желательно) заменить на свой.
|
||||
|
||||
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`. Если CF-прокси работает, медиа снова начнет загружаться.
|
||||
|
||||
## Зачем мне настраивать свой домен?
|
||||
|
||||
Cloudflare имеет лимиты на одновременное количество WS-подключений. Домен по умолчанию может перестать работать в любой момент.
|
||||
|
||||
## Настройка своего домена
|
||||
|
||||
1. Добавьте свой домен в Cloudflare (либо купив его напрямую у Cloudflare, либо изменив NS-серверы: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/). Домены стоят примерно 150 рублей в год, подойдёт любой.
|
||||
|
||||
2. В `SSL/TLS` → `Overview` выставьте режим **Flexible**.
|
||||
|
||||
3. В `DNS` → `Records` добавьте следующие `A`-записи через `+ Add Record`:
|
||||
- Name=`kws1` IPv4=`149.154.175.50`
|
||||
- Name=`kws2` IPv4=`149.154.167.51`
|
||||
- Name=`kws3` IPv4=`149.154.175.100`
|
||||
- Name=`kws4` IPv4=`149.154.167.91`
|
||||
- Name=`kws5` IPv4=`149.154.171.5`
|
||||
- Name=`kws203` IPv4=`91.105.192.100`
|
||||
|
||||
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО, так как подсеть Cloudflare может быть заблокирована (например, в России).**
|
||||
|
||||
5. В настройках `TgWsProxy` замените домен на свой.
|
||||
|
||||
## Благодарности
|
||||
|
||||
- Идея: https://github.com/Nekogram/WSProxy
|
||||
- Спасибо [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) за информацию.
|
||||
@@ -1,124 +0,0 @@
|
||||
# Cloudflare Worker
|
||||
|
||||
Альтернативный (полностью бесплатный, не нужно покупать домен в отличии от [CfProxy](./CfProxy.md)) способ проксирования.
|
||||
|
||||
Прокси возвращает доступ к тому, что раньше не загружалось (реакции, некоторые стикеры). Если на аккаунте без Premium с данным способом все еще не загружаются фото/видео, оставьте в блоке `DC → IP` только `4:149.154.167.220`
|
||||
|
||||
##
|
||||
|
||||
1. **Добавьте в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или в любое другое ПО следующие домены:**
|
||||
```
|
||||
cloudflare.com
|
||||
cloudflare.dev
|
||||
workers.dev
|
||||
```
|
||||
2. Создайте аккаунт в [Cloudflare](https://dash.cloudflare.com/) (или войдите в существующий)
|
||||
* **После создания аккаунта подтвердите почту с помощью письма, который вам пришел на email**
|
||||
3. Слева в панели выберите `Compute` → `Workers & Pages`
|
||||
<img width="250" height="768" alt="image" src="https://github.com/user-attachments/assets/d81e3522-045a-4e65-9c2e-5545b7ad409a" />
|
||||
|
||||
4. Нажмите сверху справа кнопку **`Create application`** → `Start with Hello World!` → `Deploy`
|
||||
<img width="1406" height="193" alt="image" src="https://github.com/user-attachments/assets/7ac65944-8761-42a6-ab6d-ba5f9080c883" />
|
||||
<img width="586" height="379" alt="image" src="https://github.com/user-attachments/assets/ff901439-c2a1-4867-95de-e11b82a37044" />
|
||||
<img width="624" height="694" alt="image" src="https://github.com/user-attachments/assets/bb68d49a-166d-42a0-8fe2-bd2b16c0d066" />
|
||||
|
||||
5. Сверху справа нажмите кнопку **`Edit code`**, замените код слева на тот, [что находится внизу этой страницы](./CfWorker.md#код-workerа)
|
||||
* Если у вас не загружается код, то вы не выполнили первый пункт
|
||||
<img width="911" height="117" alt="image" src="https://github.com/user-attachments/assets/6bcdf839-d776-47e9-9d18-ba0efdf53244" />
|
||||
<img width="1027" height="512" alt="image" src="https://github.com/user-attachments/assets/daf131ed-82d5-40f0-a7eb-daeb598bea40" />
|
||||
|
||||
|
||||
6. Нажмите сверху справа кнопку **`Deploy`**
|
||||
<img width="415" height="138" alt="image" src="https://github.com/user-attachments/assets/58d8f83e-d8b5-40cf-a30f-741d7311047b" />
|
||||
|
||||
7. Скопируйте домен из поля справа и укажите его в настройках **Cloudflare Worker** (или через аргумент `--cfproxy-worker-domain`)
|
||||
* Пример домена: `random-symbols-1234.username.workers.dev`
|
||||
<img width="414" height="182" alt="image" src="https://github.com/user-attachments/assets/4fb0b111-8026-4d17-b993-6c70ec37f1f5" />
|
||||
|
||||
|
||||
### Код Worker'а
|
||||
```javascript
|
||||
import { connect } from "cloudflare:sockets";
|
||||
|
||||
function toBytes(data) {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (data && typeof data.arrayBuffer === "function") {
|
||||
return data.arrayBuffer().then((ab) => new Uint8Array(ab));
|
||||
}
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
|
||||
return new Response("Expected websocket", { status: 426 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== "/apiws") {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const dst = url.searchParams.get("dst");
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
server.accept();
|
||||
|
||||
const socket = connect({ hostname: dst, port: 443 });
|
||||
const tcpReader = socket.readable.getReader();
|
||||
const tcpWriter = socket.writable.getWriter();
|
||||
|
||||
server.addEventListener("message", async (event) => {
|
||||
try {
|
||||
await tcpWriter.write(await toBytes(event.data));
|
||||
} catch {
|
||||
try {
|
||||
server.close(1011, "tcp write failed");
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("close", async () => {
|
||||
try {
|
||||
await tcpWriter.close();
|
||||
} catch {}
|
||||
try {
|
||||
socket.close();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await tcpReader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value) {
|
||||
server.send(value);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
try {
|
||||
tcpReader.releaseLock();
|
||||
} catch {}
|
||||
try {
|
||||
socket.close();
|
||||
} catch {}
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
# Fake TLS + upstream в nginx
|
||||
|
||||
Домен в параметре `--fake-tls-domain` должен указывать на тот же IP, на котором запущен прокси.
|
||||
|
||||
## Пример `nginx.conf` для stream-модуля
|
||||
|
||||
```nginx
|
||||
upstream mtproto {
|
||||
server 127.0.0.1:8446;
|
||||
}
|
||||
|
||||
map $ssl_preread_server_name $sni_name {
|
||||
hostnames;
|
||||
example.com mtproto;
|
||||
# if you have xray with selfsni running:
|
||||
# sub.example.com www;
|
||||
# default xray;
|
||||
}
|
||||
|
||||
# upstream xray {
|
||||
# server 127.0.0.1:8443;
|
||||
# }
|
||||
#
|
||||
# upstream www {
|
||||
# server 127.0.0.1:7443;
|
||||
# }
|
||||
|
||||
server {
|
||||
proxy_protocol on;
|
||||
set_real_ip_from unix:;
|
||||
listen 443;
|
||||
proxy_pass $sni_name;
|
||||
ssl_preread on;
|
||||
}
|
||||
```
|
||||
|
||||
## Запуск прокси за Nginx
|
||||
|
||||
```bash
|
||||
python3 proxy/tg_ws_proxy.py \
|
||||
--port 8446 \
|
||||
--host 127.0.0.1 \
|
||||
--fake-tls-domain example.com \
|
||||
--proxy-protocol \
|
||||
--secret <32-hex-chars>
|
||||
```
|
||||
|
||||
Ссылка для подключения будет в формате `ee`-секрета:
|
||||
|
||||
```text
|
||||
tg://proxy?server=your.domain.com&port=443&secret=ee<secret><domain_hex>
|
||||
```
|
||||
@@ -1,12 +0,0 @@
|
||||
> [!TIP]
|
||||
>
|
||||
> ### 🎉 Поддержать меня
|
||||
>
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
Проект полностью бесплатен для всех.
|
||||
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
|
||||
Буду благодарен за любую форму поддержки! Спасибо ❤️
|
||||
@@ -1,70 +0,0 @@
|
||||
# TG WS Proxy для Docker
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Вводите команды последовательно, одну за другой:
|
||||
|
||||
```bash
|
||||
# Скачиваем репозиторий
|
||||
git clone https://github.com/Flowseal/tg-ws-proxy.git
|
||||
|
||||
# Переходим в папку с проектом
|
||||
cd tg-ws-proxy
|
||||
|
||||
# Собираем образ
|
||||
docker build -t tg-ws-proxy .
|
||||
|
||||
# Запускаем контейнер
|
||||
docker run -d \
|
||||
--name tg-ws-proxy \
|
||||
--restart=always \
|
||||
-p 1443:1443 \
|
||||
tg-ws-proxy:latest
|
||||
|
||||
# Получаем ссылку для подключения
|
||||
docker logs tg-ws-proxy 2>&1 | grep 'tg://proxy'
|
||||
```
|
||||
|
||||
После выполнения последней команды вы увидите ссылку вида:
|
||||
|
||||
```text
|
||||
tg://proxy?server=172.17.0.2&port=1443&secret=dd68f127db1d...
|
||||
```
|
||||
|
||||
## Настройка параметров
|
||||
|
||||
Все настройки задаются переменными окружения при запуске контейнера:
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
| ----------------------- | --------------------------------- | ------------------------------------- |
|
||||
| `TG_WS_PROXY_HOST` | `Адрес для приёма подключений` | `0.0.0.0` |
|
||||
| `TG_WS_PROXY_PORT` | `Порт внутри контейнера` | `1443` |
|
||||
| `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_CF_WORKER` | `Домен Cloudflare Worker` | `None` |
|
||||
|
||||
Пример с ручным указанием секрета:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tg-ws-proxy \
|
||||
--restart=always \
|
||||
-p 1443:1443 \
|
||||
-e TG_WS_PROXY_SECRET="ваш_секрет" \
|
||||
tg-ws-proxy:latest
|
||||
```
|
||||
|
||||
Для генерации секрета можно использовать:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавьте прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
@@ -1,51 +0,0 @@
|
||||
# TG WS Proxy для Linux
|
||||
|
||||
## Готовые сборки
|
||||
|
||||
Для Debian/Ubuntu скачайте со [страницы релизов](https://github.com/Flowseal/tg-ws-proxy/releases) пакет `TgWsProxy_linux_amd64.deb`.
|
||||
|
||||
Для Arch и основанных на Arch дистрибутивов подготовлены пакеты в AUR:
|
||||
|
||||
- [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin)
|
||||
- [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git)
|
||||
- [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli)
|
||||
|
||||
```shell
|
||||
# Установка без AUR-helper
|
||||
git clone https://aur.archlinux.org/tg-ws-proxy-bin.git
|
||||
cd tg-ws-proxy-bin
|
||||
makepkg -si
|
||||
|
||||
# При помощи AUR-helper
|
||||
paru -S tg-ws-proxy-bin
|
||||
|
||||
# Для пакета -cli запуск через systemd (8888 — номер порта; secret можно сгенерировать командой openssl rand -hex 16)
|
||||
sudo systemctl start tg-ws-proxy@8888:3075abe65830f0325116bb0416cadf9f
|
||||
```
|
||||
|
||||
Для остальных дистрибутивов можно использовать `TgWsProxy_linux_amd64` (бинарный файл для x86_64).
|
||||
|
||||
```bash
|
||||
chmod +x TgWsProxy_linux_amd64
|
||||
./TgWsProxy_linux_amd64
|
||||
```
|
||||
|
||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавьте прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-linux
|
||||
```
|
||||
@@ -1,30 +0,0 @@
|
||||
# TG WS Proxy для macOS
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте `TgWsProxy_macos_universal.dmg` (универсальная сборка для Apple Silicon и Intel).
|
||||
|
||||
1. Откройте образ
|
||||
2. Перенесите `TG WS Proxy.app` в папку `Applications`
|
||||
3. При первом запуске macOS может попросить подтвердить открытие: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**
|
||||
|
||||
Минимально поддерживаемые версии:
|
||||
|
||||
- Intel macOS 10.15+
|
||||
- Apple Silicon macOS 11.0+
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавьте прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-macos
|
||||
```
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<div align="center">
|
||||
<br />
|
||||
<p>
|
||||
<img width="1729" height="910" alt="tgwsproxy" src="./images/workflow.png" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
##
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> ### [🎉 Поддержать меня](./Funding.md)
|
||||
>
|
||||
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
> **Другие монеты**: https://nowpayments.io/donation/flowseal
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> ### Реакция антивирусов
|
||||
>
|
||||
> Антивирусы часто ошибочно помечают приложение как вирус из-за упаковщика.
|
||||
> Если вы не можете скачать из-за блокировки антивирусом, то:
|
||||
>
|
||||
> 1) **Попробуйте скачать версию для Windows 7 (по функциональности она не отличается)**
|
||||
> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно
|
||||
>
|
||||
> Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal
|
||||
|
||||
# TG WS Proxy
|
||||
|
||||
**Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние серверы.
|
||||
|
||||
<picture>
|
||||
<source srcset="./images/preview-dark.png" media="(prefers-color-scheme: dark)">
|
||||
<img src="./images/preview-white.png">
|
||||
</picture>
|
||||
|
||||
## Навигация
|
||||
|
||||
- **🚀 Быстрый старт**
|
||||
- **[Windows](./README.windows.md)**
|
||||
- **[macOS](./README.macos.md)**
|
||||
- **[Linux](./README.linux.md)**
|
||||
- **[Docker](./README.docker.md)**
|
||||
- [Настройка Cloudflare Worker'а (бесплатный аналог CF-прокси)](./CfWorker.md)
|
||||
- [Настройка Cloudflare-домена (CF-прокси)](./CfProxy.md)
|
||||
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
||||
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
||||
- [Установка из исходников](./BuildFromSource.md)
|
||||
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
|
||||
|
||||
## Windows: быстрый вход
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||
|
||||
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||
|
||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||
|
||||
### Меню трея
|
||||
|
||||
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||
- **Открыть логи** — открыть файл логов
|
||||
- **Выход** — остановить прокси и закрыть приложение
|
||||
|
||||
### Настройка Telegram Desktop
|
||||
|
||||
**Автоматическая настройка**
|
||||
|
||||
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||
|
||||
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||
|
||||
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||
3. Подключитесь
|
||||
|
||||
**Ручная настройка**
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавьте прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Как это работает
|
||||
|
||||
```
|
||||
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
|
||||
```
|
||||
|
||||
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
|
||||
2. Перехватывает подключения к IP-адресам Telegram
|
||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||
4. Устанавливает WebSocket-соединение (TLS) к соответствующему DC через домены Telegram
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### Не грузит фото/видео?
|
||||
> **Удалите в настройках прокси в DC → IP всё, кроме `4:149.154.167.220`**
|
||||
> **Если это не помогло, полностью очистите это поле**
|
||||
> Подобная проблема встречается на аккаунтах без Premium
|
||||
> Если это не помогло, настройте собственный домен по инструкции: [CfProxy.md](./CfProxy.md)
|
||||
|
||||
## Автоматическая сборка
|
||||
|
||||
Проект содержит спецификации PyInstaller ([`packaging/windows.spec`](../packaging/windows.spec), [`packaging/macos.spec`](../packaging/macos.spec), [`packaging/linux.spec`](../packaging/linux.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](../.github/workflows/build.yml)) для автоматической сборки.
|
||||
|
||||
Минимально поддерживаемые версии ОС для текущих бинарных сборок:
|
||||
|
||||
- Windows 10+ x64 для `TgWsProxy_windows.exe`
|
||||
- Windows 10+ ARM64 для `TgWsProxy_windows_arm64.exe`
|
||||
- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe`
|
||||
- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe`
|
||||
- Intel macOS 10.15+
|
||||
- Apple Silicon macOS 11.0+
|
||||
- Linux x86_64 (требуется AppIndicator для системного трея)
|
||||
|
||||
## Контрибьюторы
|
||||
|
||||
Спасибо всем, кто помогает развивать проект ❤️
|
||||
|
||||
<a href="https://github.com/Flowseal/tg-ws-proxy/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Flowseal/tg-ws-proxy" />
|
||||
</a>
|
||||
|
||||
## Лицензия
|
||||
|
||||
[MIT License](../LICENSE)
|
||||
@@ -1,57 +0,0 @@
|
||||
# TG WS Proxy для Windows
|
||||
|
||||
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
|
||||
|
||||
- `TgWsProxy_windows.exe` (Windows 10+ x64)
|
||||
- `TgWsProxy_windows_arm64.exe` (Windows 10+ ARM64)
|
||||
- `TgWsProxy_windows_7_64bit.exe` (Windows 7 x64)
|
||||
- `TgWsProxy_windows_7_32bit.exe` (Windows 7 x32)
|
||||
|
||||
Сборки публикуются автоматически через [GitHub Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода.
|
||||
|
||||
При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. **Приложение сворачивается в системный трей.**
|
||||
|
||||
## Меню трея
|
||||
|
||||
- **Открыть в Telegram** — автоматически настроить прокси через ссылку `tg://proxy`
|
||||
- **Скопировать ссылку** — скопировать ссылку для подключения
|
||||
- **Перезапустить прокси** — перезапуск без выхода из приложения
|
||||
- **Настройки...** — GUI-редактор конфигурации (версия приложения, опциональная проверка обновлений с GitHub)
|
||||
- **Открыть логи** — открыть файл логов
|
||||
- **Выход** — остановить прокси и закрыть приложение
|
||||
|
||||
При первом запуске после старта может появиться запрос об открытии страницы релиза, если на GitHub вышла новая версия (эту проверку можно отключить в настройках).
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
### Автоматическая настройка
|
||||
|
||||
Щелкните правой кнопкой мыши по значку в трее и выберите **«Открыть в Telegram»**.
|
||||
|
||||
Если не сработало (Telegram не открылся с подключением), выполните шаги ниже:
|
||||
|
||||
1. Щелкните правой кнопкой мыши по значку в трее и выберите **«Скопировать ссылку»**
|
||||
2. Отправьте ссылку в «Избранное» в Telegram и нажмите по ней левой кнопкой мыши
|
||||
3. Подключитесь
|
||||
|
||||
### Ручная настройка
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
2. Добавьте прокси:
|
||||
- **Тип:** MTProto
|
||||
- **Сервер:** `127.0.0.1` (или переопределенный вами)
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Портативный режим
|
||||
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
tg-ws-proxy-tray-win
|
||||
```
|
||||
@@ -1,31 +0,0 @@
|
||||
# Файлы конфигурации Tray-приложения
|
||||
|
||||
Tray-приложение хранит данные в:
|
||||
|
||||
- **Windows:** `%APPDATA%/TgWsProxy`
|
||||
- **macOS:** `~/Library/Application Support/TgWsProxy`
|
||||
- **Linux:** `~/.config/TgWsProxy` (или `$XDG_CONFIG_HOME/TgWsProxy`)
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 1443,
|
||||
"secret": "...",
|
||||
"dc_ip": [
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220"
|
||||
],
|
||||
"verbose": false,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"log_max_mb": 5.0,
|
||||
"check_updates": true,
|
||||
"cfproxy": true,
|
||||
"cfproxy_user_domain": "",
|
||||
"cfproxy_worker_domain": "",
|
||||
"appearance": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
|
||||
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -12,7 +12,7 @@ import pyperclip
|
||||
import pystray
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from proxy import get_link_host
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
|
||||
from utils.tray_common import (
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
||||
@@ -30,7 +30,6 @@ from ui.ctk_theme import (
|
||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||
)
|
||||
from ui.i18n import set_language, t
|
||||
|
||||
_tray_icon: Optional[object] = None
|
||||
_config: dict = {}
|
||||
@@ -54,16 +53,16 @@ def _msgbox(kind: str, text: str, title: str, **kw):
|
||||
return result
|
||||
|
||||
|
||||
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||
_msgbox("showerror", text, title or t("app.error_title"))
|
||||
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
|
||||
_msgbox("showerror", text, title)
|
||||
|
||||
|
||||
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||
_msgbox("showinfo", text, title or t("app.name"))
|
||||
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
|
||||
_msgbox("showinfo", text, title)
|
||||
|
||||
|
||||
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||
return bool(_msgbox("askyesno", text, title or t("app.name")))
|
||||
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
|
||||
return bool(_msgbox("askyesno", text, title))
|
||||
|
||||
|
||||
def _apply_window_icon(root) -> None:
|
||||
@@ -81,10 +80,12 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
|
||||
log.info("Copying %s", url)
|
||||
try:
|
||||
pyperclip.copy(url)
|
||||
_show_info(t("dialog.copy_ok", url=url))
|
||||
_show_info(
|
||||
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Clipboard copy failed: %s", exc)
|
||||
_show_error(t("dialog.copy_fail", error=exc))
|
||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
||||
|
||||
|
||||
def _on_copy_link(icon=None, item=None) -> None:
|
||||
@@ -94,7 +95,7 @@ def _on_copy_link(icon=None, item=None) -> None:
|
||||
pyperclip.copy(url)
|
||||
except Exception as exc:
|
||||
log.error("Clipboard copy failed: %s", exc)
|
||||
_show_error(t("dialog.copy_fail", error=exc))
|
||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
||||
|
||||
|
||||
def _on_restart(icon=None, item=None) -> None:
|
||||
@@ -117,7 +118,7 @@ def _on_open_logs(icon=None, item=None) -> None:
|
||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
else:
|
||||
_show_info(t("dialog.log_not_found"))
|
||||
_show_info("Файл логов ещё не создан.")
|
||||
|
||||
|
||||
def _on_exit(icon=None, item=None) -> None:
|
||||
@@ -137,8 +138,8 @@ def _on_exit(icon=None, item=None) -> None:
|
||||
|
||||
|
||||
def _edit_config_dialog() -> None:
|
||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
_show_error(t("dialog.ctk_missing"))
|
||||
if not ensure_ctk_thread(ctk):
|
||||
_show_error("customtkinter не установлен.")
|
||||
return
|
||||
|
||||
cfg = dict(_config)
|
||||
@@ -147,77 +148,40 @@ def _edit_config_dialog() -> None:
|
||||
theme = ctk_theme_for_platform()
|
||||
w, h = CONFIG_DIALOG_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
|
||||
after_create=_apply_window_icon,
|
||||
)
|
||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
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(
|
||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||
show_autostart=False,
|
||||
on_language_change=_refresh_tray_menu,
|
||||
)
|
||||
|
||||
_original_appearance = ctk.get_appearance_mode()
|
||||
|
||||
def _restore_ui_locale() -> None:
|
||||
set_language(_original_language)
|
||||
_refresh_tray_menu()
|
||||
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
|
||||
|
||||
def _finish() -> None:
|
||||
root.destroy()
|
||||
done.set()
|
||||
|
||||
def _cancel() -> None:
|
||||
ctk.set_appearance_mode(_original_appearance)
|
||||
_restore_ui_locale()
|
||||
_finish()
|
||||
|
||||
def on_save() -> None:
|
||||
from tkinter import messagebox
|
||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
|
||||
if isinstance(merged, str):
|
||||
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
||||
return
|
||||
|
||||
_ui_only_keys = {"appearance", "check_updates", "language"}
|
||||
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
|
||||
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
|
||||
|
||||
if not config_changed:
|
||||
_restore_ui_locale()
|
||||
_finish()
|
||||
return
|
||||
|
||||
save_config(merged)
|
||||
_config.update(merged)
|
||||
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
|
||||
log.info("Config saved: %s", merged)
|
||||
_tray_icon.menu = _build_menu()
|
||||
|
||||
if not proxy_changed:
|
||||
_finish()
|
||||
return
|
||||
|
||||
do_restart = messagebox.askyesno(
|
||||
t("dialog.restart_title"),
|
||||
t("dialog.restart_body"),
|
||||
"Перезапустить?",
|
||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||
parent=root,
|
||||
)
|
||||
_finish()
|
||||
if do_restart:
|
||||
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", _cancel)
|
||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
||||
root.protocol("WM_DELETE_WINDOW", _finish)
|
||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
|
||||
|
||||
ctk_run_dialog(_build)
|
||||
|
||||
@@ -229,7 +193,7 @@ def _show_first_run() -> None:
|
||||
ensure_dirs()
|
||||
if FIRST_RUN_MARKER.exists():
|
||||
return
|
||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
if not ensure_ctk_thread(ctk):
|
||||
FIRST_RUN_MARKER.touch()
|
||||
return
|
||||
|
||||
@@ -241,7 +205,7 @@ def _show_first_run() -> None:
|
||||
theme = ctk_theme_for_platform()
|
||||
w, h = FIRST_RUN_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
|
||||
after_create=_apply_window_icon,
|
||||
)
|
||||
|
||||
@@ -263,16 +227,16 @@ def _show_first_run() -> None:
|
||||
def _build_menu():
|
||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
return pystray.Menu(
|
||||
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||
pystray.MenuItem("Перезапустить прокси", _on_restart),
|
||||
pystray.MenuItem("Настройки...", _on_edit_config),
|
||||
pystray.MenuItem("Открыть логи", _on_open_logs),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||
pystray.MenuItem("Выход", _on_exit),
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +264,7 @@ def run_tray() -> None:
|
||||
_show_first_run()
|
||||
check_ipv6_warning(_show_info)
|
||||
|
||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
|
||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
|
||||
log.info("Tray icon running")
|
||||
_tray_icon.run()
|
||||
|
||||
@@ -309,8 +273,8 @@ def run_tray() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not acquire_lock():
|
||||
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||
if not acquire_lock("linux.py"):
|
||||
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
|
||||
return
|
||||
try:
|
||||
run_tray()
|
||||
|
||||
@@ -9,67 +9,29 @@ import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
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:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
Image = ImageDraw = ImageFont = None
|
||||
|
||||
try:
|
||||
import pyperclip
|
||||
except ImportError:
|
||||
pyperclip = None
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from proxy.tg_ws_proxy import _run
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
from proxy import __version__
|
||||
|
||||
from utils.tray_common import (
|
||||
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
|
||||
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
|
||||
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"
|
||||
|
||||
@@ -79,8 +41,6 @@ _app: Optional[object] = None
|
||||
_config: dict = {}
|
||||
_exiting: bool = False
|
||||
|
||||
_CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md"
|
||||
|
||||
# osascript dialogs
|
||||
|
||||
|
||||
@@ -149,42 +109,32 @@ def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> O
|
||||
return r.stdout.rstrip("\r\n")
|
||||
|
||||
|
||||
def _ask_cfworker_domain(default: str) -> Optional[str]:
|
||||
value = default
|
||||
while True:
|
||||
script = (
|
||||
f'set d to display dialog "{_esc("Cloudflare Worker домены через запятую (например, name.account.workers.dev):")}" '
|
||||
f'default answer "{_esc(value)}" '
|
||||
f'with title "TG WS Proxy" '
|
||||
f'buttons {{"Закрыть", "?", "OK"}} '
|
||||
f'default button "OK" cancel button "Закрыть"\n'
|
||||
f'return (button returned of d) & "\\n" & (text returned of d)'
|
||||
)
|
||||
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
|
||||
out_lines = r.stdout.splitlines()
|
||||
button = out_lines[0].strip() if out_lines else ""
|
||||
value = out_lines[1].strip() if len(out_lines) > 1 else value
|
||||
|
||||
if button == "?":
|
||||
webbrowser.open(_CFWORKER_HELP_URL)
|
||||
continue
|
||||
if button == "OK":
|
||||
return value.strip()
|
||||
|
||||
|
||||
# menubar icon
|
||||
|
||||
|
||||
def _make_menubar_icon(size: int = 44):
|
||||
if Image is None:
|
||||
return None
|
||||
return render_app_icon(size)
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
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:
|
||||
if MENUBAR_ICON_PATH.exists():
|
||||
return
|
||||
ensure_dirs()
|
||||
img = _make_menubar_icon(44)
|
||||
if img:
|
||||
@@ -203,12 +153,16 @@ def _run_proxy_thread() -> None:
|
||||
stop_ev = _asyncio.Event()
|
||||
_async_stop = (loop, stop_ev)
|
||||
try:
|
||||
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
||||
except Exception as exc:
|
||||
log.error("Proxy thread crashed: %s", exc)
|
||||
msg, _ = diagnose_listen_error(exc)
|
||||
if msg:
|
||||
_show_error(msg)
|
||||
if "Address already in use" in str(exc):
|
||||
_show_error(
|
||||
"Не удалось запустить прокси:\n"
|
||||
"Порт уже используется другим приложением.\n\n"
|
||||
"Закройте приложение, использующее этот порт, "
|
||||
"или измените порт в настройках прокси и перезапустите."
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
_async_stop = None
|
||||
@@ -222,7 +176,7 @@ def _start_proxy() -> None:
|
||||
if not apply_proxy_config(_config):
|
||||
_show_error("Ошибка конфигурации DC → IP.")
|
||||
return
|
||||
pc = proxy_config
|
||||
pc = tg_ws_proxy.proxy_config
|
||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
|
||||
_proxy_thread.start()
|
||||
@@ -355,7 +309,7 @@ def _maybe_notify_update_async() -> None:
|
||||
):
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", exc)
|
||||
log.debug("Update check failed: %s", exc)
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||
|
||||
@@ -408,7 +362,7 @@ def _edit_config_dialog() -> None:
|
||||
return
|
||||
dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()]
|
||||
try:
|
||||
parse_dc_ip_list(dc_lines)
|
||||
tg_ws_proxy.parse_dc_ip_list(dc_lines)
|
||||
except ValueError as e:
|
||||
_show_error(str(e))
|
||||
return
|
||||
@@ -438,30 +392,6 @@ def _edit_config_dialog() -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cfproxy = _ask_yes_no_close("Включить Cloudflare Proxy (CfProxy)?")
|
||||
if cfproxy is None:
|
||||
return
|
||||
|
||||
cfproxy_domain = _osascript_input(
|
||||
"Свои CF-домены через запятую (оставьте пустым для автоматического выбора):\n"
|
||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
|
||||
", ".join(coerce_domain_list(
|
||||
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", []))
|
||||
)),
|
||||
)
|
||||
if cfproxy_domain is None:
|
||||
return
|
||||
cfproxy_domains = coerce_domain_list(cfproxy_domain)
|
||||
|
||||
cfworker_domain = _ask_cfworker_domain(
|
||||
", ".join(coerce_domain_list(
|
||||
cfg.get("cfproxy_worker_domain", DEFAULT_CONFIG.get("cfproxy_worker_domain", []))
|
||||
))
|
||||
)
|
||||
if cfworker_domain is None:
|
||||
return
|
||||
cfworker_domains = coerce_domain_list(cfworker_domain)
|
||||
|
||||
new_cfg = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
@@ -472,9 +402,6 @@ def _edit_config_dialog() -> None:
|
||||
"pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])),
|
||||
"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),
|
||||
"cfproxy": cfproxy,
|
||||
"cfproxy_user_domain": cfproxy_domains,
|
||||
"cfproxy_worker_domain": cfworker_domains,
|
||||
}
|
||||
save_config(new_cfg)
|
||||
log.info("Config saved: %s", new_cfg)
|
||||
@@ -500,7 +427,7 @@ def _show_first_run() -> None:
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
|
||||
tg_url = tg_proxy_url(_config)
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
|
||||
text = (
|
||||
f"Прокси запущен и работает в строке меню.\n\n"
|
||||
@@ -569,7 +496,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
||||
|
||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
|
||||
self._open_tg_item = rumps.MenuItem(
|
||||
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
|
||||
@@ -609,7 +536,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
|
||||
def update_menu_title(self) -> None:
|
||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
|
||||
|
||||
|
||||
@@ -660,7 +587,7 @@ def run_menubar() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not acquire_lock():
|
||||
if not acquire_lock("macos.py"):
|
||||
_show_info("Приложение уже запущено.")
|
||||
return
|
||||
try:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,93 +0,0 @@
|
||||
#!/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"
|
||||
+2
-18
@@ -12,8 +12,6 @@ block_cipher = None
|
||||
import customtkinter
|
||||
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
|
||||
gi_hiddenimports = collect_submodules('gi')
|
||||
gi_datas = collect_data_files('gi')
|
||||
@@ -28,7 +26,7 @@ a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + gi_datas + typelib_datas,
|
||||
datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas,
|
||||
hiddenimports=[
|
||||
'pystray._appindicator',
|
||||
'PIL._tkinter_finder',
|
||||
@@ -48,25 +46,11 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
_PIL_EXCLUDE_PYDS = {
|
||||
'_avif', '_webp', '_imagingtk',
|
||||
'FpxImagePlugin', 'MicImagePlugin',
|
||||
}
|
||||
a.binaries = [
|
||||
(name, path, typ)
|
||||
for name, path, typ in a.binaries
|
||||
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||
]
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||
if os.path.exists(icon_path):
|
||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||
|
||||
+2
-18
@@ -5,13 +5,11 @@ import os
|
||||
|
||||
block_cipher = None
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(_i18n_path, 'ui/i18n')],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
'rumps',
|
||||
'objc',
|
||||
@@ -27,25 +25,11 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
_PIL_EXCLUDE_PYDS = {
|
||||
'_avif', '_webp', '_imagingtk',
|
||||
'FpxImagePlugin', 'MicImagePlugin',
|
||||
}
|
||||
a.binaries = [
|
||||
(name, path, typ)
|
||||
for name, path, typ in a.binaries
|
||||
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||
]
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns')
|
||||
if not os.path.exists(icon_path):
|
||||
icon_path = None
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# UTF-8
|
||||
#
|
||||
# For more details about fixed file info 'ffi' see:
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 8, 1, 0),
|
||||
prodvers=(1, 8, 1, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
u'040904B0',
|
||||
[
|
||||
StringStruct(u'CompanyName', u'Flowseal'),
|
||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||
StringStruct(u'FileVersion', u'1.8.1.0'),
|
||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||
StringStruct(u'ProductVersion', u'1.8.1.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
+3
-21
@@ -9,13 +9,11 @@ block_cipher = None
|
||||
import customtkinter
|
||||
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')],
|
||||
datas=[(ctk_path, 'customtkinter/')],
|
||||
hiddenimports=[
|
||||
'pystray._win32',
|
||||
'PIL._tkinter_finder',
|
||||
@@ -28,29 +26,14 @@ a = Analysis(
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
'PIL._avif',
|
||||
'PIL._webp',
|
||||
'PIL._imagingtk',
|
||||
],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
_PIL_EXCLUDE_PYDS = {
|
||||
'_avif', '_webp', '_imagingtk',
|
||||
'FpxImagePlugin', 'MicImagePlugin',
|
||||
}
|
||||
a.binaries = [
|
||||
(name, path, typ)
|
||||
for name, path, typ in a.binaries
|
||||
if not any(ex in name for ex in _PIL_EXCLUDE_PYDS)
|
||||
]
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||
if os.path.exists(icon_path):
|
||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||
|
||||
@@ -67,7 +50,7 @@ exe = EXE(
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
@@ -77,5 +60,4 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=icon_path if os.path.exists(icon_path) else None,
|
||||
version=version_path if os.path.exists(version_path) else None,
|
||||
)
|
||||
|
||||
+1
-6
@@ -1,6 +1 @@
|
||||
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from .utils import get_link_host, build_github_opener
|
||||
|
||||
__version__ = "1.8.1"
|
||||
|
||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||
__version__ = "1.4.0"
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"port": 1443,
|
||||
"host": "127.0.0.1",
|
||||
"secret": os.urandom(16).hex(),
|
||||
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
|
||||
"log_max_mb": 5,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"verbose": False,
|
||||
}
|
||||
|
||||
|
||||
class ProxyAppRuntime:
|
||||
def __init__(self, app_dir: Path,
|
||||
default_config: Optional[dict] = None,
|
||||
logger_name: str = "tg-ws-runtime",
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
parse_dc_ip_list: Optional[
|
||||
Callable[[list[str]], Dict[int, str]]
|
||||
] = None,
|
||||
run_proxy: Optional[Callable[..., object]] = None,
|
||||
thread_factory: Optional[Callable[..., object]] = None):
|
||||
self.app_dir = Path(app_dir)
|
||||
self.config_file = self.app_dir / "config.json"
|
||||
self.log_file = self.app_dir / "proxy.log"
|
||||
self.default_config = dict(default_config or DEFAULT_CONFIG)
|
||||
self.log = logging.getLogger(logger_name)
|
||||
self.on_error = on_error
|
||||
self.parse_dc_ip_list = parse_dc_ip_list or \
|
||||
tg_ws_proxy.parse_dc_ip_list
|
||||
self.run_proxy = run_proxy or tg_ws_proxy._run
|
||||
self.thread_factory = thread_factory or threading.Thread
|
||||
self.config: dict = {}
|
||||
self._proxy_thread = None
|
||||
self._async_stop = None
|
||||
|
||||
def _build_core_config(self, active_cfg: dict, dc_opt: Dict[int, str]):
|
||||
port = int(active_cfg.get("port", self.default_config["port"]))
|
||||
host = str(active_cfg.get("host", self.default_config["host"]))
|
||||
secret = str(active_cfg.get("secret") or "").strip()
|
||||
if not secret:
|
||||
secret = os.urandom(16).hex()
|
||||
active_cfg["secret"] = secret
|
||||
|
||||
buf_kb = int(active_cfg.get("buf_kb", self.default_config["buf_kb"]))
|
||||
pool_size = int(active_cfg.get(
|
||||
"pool_size", self.default_config["pool_size"]))
|
||||
|
||||
return tg_ws_proxy.ProxyConfig(
|
||||
port=port,
|
||||
host=host,
|
||||
secret=secret,
|
||||
dc_redirects=dc_opt,
|
||||
buffer_size=max(4, buf_kb) * 1024,
|
||||
pool_size=max(0, pool_size),
|
||||
)
|
||||
|
||||
def ensure_dirs(self):
|
||||
self.app_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load_config(self) -> dict:
|
||||
self.ensure_dirs()
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for key, value in self.default_config.items():
|
||||
data.setdefault(key, value)
|
||||
self.config = data
|
||||
return data
|
||||
except Exception as exc:
|
||||
self.log.warning("Failed to load config: %s", exc)
|
||||
|
||||
self.config = dict(self.default_config)
|
||||
return dict(self.config)
|
||||
|
||||
def save_config(self, cfg: dict):
|
||||
self.ensure_dirs()
|
||||
self.config = dict(cfg)
|
||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def reset_log_file(self):
|
||||
if self.log_file.exists():
|
||||
try:
|
||||
self.log_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setup_logging(self, verbose: bool = False, log_max_mb: float = 5):
|
||||
self.ensure_dirs()
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
for handler in list(root.handlers):
|
||||
if getattr(handler, "_tg_ws_proxy_runtime_handler", False):
|
||||
root.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
str(self.log_file),
|
||||
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
|
||||
backupCount=0,
|
||||
encoding="utf-8",
|
||||
)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
fh._tg_ws_proxy_runtime_handler = True
|
||||
root.addHandler(fh)
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
ch.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s %(message)s",
|
||||
datefmt="%H:%M:%S"))
|
||||
ch._tg_ws_proxy_runtime_handler = True
|
||||
root.addHandler(ch)
|
||||
|
||||
def prepare(self) -> dict:
|
||||
cfg = self.load_config()
|
||||
self.save_config(cfg)
|
||||
return cfg
|
||||
|
||||
def _emit_error(self, text: str):
|
||||
if self.on_error:
|
||||
self.on_error(text)
|
||||
|
||||
def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str],
|
||||
host: str = "127.0.0.1"):
|
||||
loop = _asyncio.new_event_loop()
|
||||
_asyncio.set_event_loop(loop)
|
||||
stop_ev = _asyncio.Event()
|
||||
self._async_stop = (loop, stop_ev)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(self.run_proxy(stop_event=stop_ev))
|
||||
except Exception as exc:
|
||||
self.log.error("Proxy thread crashed: %s", exc)
|
||||
exc_text = str(exc)
|
||||
if ("10048" in exc_text or
|
||||
"address already in use" in exc_text.lower()):
|
||||
self._emit_error(
|
||||
"Не удалось запустить прокси:\n"
|
||||
"Порт уже используется другим приложением.\n\n"
|
||||
"Закройте приложение, использующее этот порт, "
|
||||
"или измените порт в настройках прокси и перезапустите.")
|
||||
else:
|
||||
self._emit_error(str(exc) or exc.__class__.__name__)
|
||||
finally:
|
||||
loop.close()
|
||||
self._async_stop = None
|
||||
|
||||
def start_proxy(self, cfg: Optional[dict] = None) -> bool:
|
||||
if self._proxy_thread and self._proxy_thread.is_alive():
|
||||
self.log.info("Proxy already running")
|
||||
return True
|
||||
|
||||
active_cfg = dict(cfg or self.config or self.default_config)
|
||||
self.config = dict(active_cfg)
|
||||
port = active_cfg.get("port", self.default_config["port"])
|
||||
host = active_cfg.get("host", self.default_config["host"])
|
||||
dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"])
|
||||
buf_kb = active_cfg.get("buf_kb", self.default_config["buf_kb"])
|
||||
pool_size = active_cfg.get(
|
||||
"pool_size", self.default_config["pool_size"])
|
||||
|
||||
try:
|
||||
dc_opt = self.parse_dc_ip_list(dc_ip_list)
|
||||
except ValueError as exc:
|
||||
self.log.error("Bad config dc_ip: %s", exc)
|
||||
self._emit_error("Ошибка конфигурации:\n%s" % exc)
|
||||
return False
|
||||
|
||||
tg_ws_proxy.proxy_config = self._build_core_config(active_cfg, dc_opt)
|
||||
self.save_config(active_cfg)
|
||||
|
||||
self.log.info("Starting proxy on %s:%d ...", host, port)
|
||||
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
|
||||
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
|
||||
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
|
||||
self._proxy_thread = self.thread_factory(
|
||||
target=self._run_proxy_thread,
|
||||
args=(
|
||||
port,
|
||||
dc_opt,
|
||||
host,
|
||||
),
|
||||
daemon=True,
|
||||
name="proxy")
|
||||
self._proxy_thread.start()
|
||||
return True
|
||||
|
||||
def stop_proxy(self):
|
||||
if self._async_stop:
|
||||
loop, stop_ev = self._async_stop
|
||||
loop.call_soon_threadsafe(stop_ev.set)
|
||||
if self._proxy_thread:
|
||||
self._proxy_thread.join(timeout=2)
|
||||
self._proxy_thread = None
|
||||
self.log.info("Proxy stopped")
|
||||
|
||||
def restart_proxy(self, delay_seconds: float = 0.3) -> bool:
|
||||
self.log.info("Restarting proxy...")
|
||||
self.stop_proxy()
|
||||
time.sleep(delay_seconds)
|
||||
return self.start_proxy()
|
||||
|
||||
def is_proxy_running(self) -> bool:
|
||||
return bool(self._proxy_thread and self._proxy_thread.is_alive())
|
||||
@@ -1,43 +0,0 @@
|
||||
import random
|
||||
from collections import Counter
|
||||
|
||||
from typing import Dict, List, Iterator
|
||||
|
||||
|
||||
class _Balancer:
|
||||
def __init__(self):
|
||||
self.domains: List[str] = []
|
||||
self._dc_to_domain: Dict[int, str] = {}
|
||||
|
||||
def update_domains_list(self, domains_list: List[str]) -> None:
|
||||
if Counter(self.domains) == Counter(domains_list):
|
||||
return
|
||||
|
||||
self.domains = domains_list[:]
|
||||
|
||||
self._dc_to_domain = {
|
||||
dc_id: random.choice(self.domains)
|
||||
for dc_id in (1, 2, 3, 4, 5, 203)
|
||||
}
|
||||
|
||||
def update_domain_for_dc(self, dc_id: int, domain: str) -> bool:
|
||||
if self._dc_to_domain.get(dc_id) == domain:
|
||||
return False
|
||||
|
||||
self._dc_to_domain[dc_id] = domain
|
||||
return True
|
||||
|
||||
def get_domains_for_dc(self, dc_id: int) -> Iterator[str]:
|
||||
current_domain = self._dc_to_domain.get(dc_id)
|
||||
if current_domain is not None:
|
||||
yield current_domain
|
||||
|
||||
shuffled_domains = self.domains[:]
|
||||
random.shuffle(shuffled_domains)
|
||||
|
||||
for domain in shuffled_domains:
|
||||
if domain != current_domain:
|
||||
yield domain
|
||||
|
||||
|
||||
balancer = _Balancer()
|
||||
-424
@@ -1,424 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import random
|
||||
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .utils import *
|
||||
from .stats import stats
|
||||
from .balancer import balancer
|
||||
from .config import proxy_config
|
||||
from .raw_websocket import RawWebSocket
|
||||
from .pool import cf_worker_pool
|
||||
from ._aes import Cipher, algorithms, modes
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
_st_I_le = struct.Struct('<I')
|
||||
|
||||
ZERO_64 = b'\x00' * 64
|
||||
|
||||
|
||||
class CryptoCtx:
|
||||
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
|
||||
|
||||
def __init__(self, clt_dec, clt_enc, tg_enc, tg_dec):
|
||||
self.clt_dec = clt_dec # decrypt from client
|
||||
self.clt_enc = clt_enc # encrypt to client
|
||||
self.tg_enc = tg_enc # encrypt to telegram
|
||||
self.tg_dec = tg_dec # decrypt from telegram
|
||||
|
||||
|
||||
class MsgSplitter:
|
||||
"""
|
||||
Splits TCP stream data into individual MTProto transport packets
|
||||
so each can be sent as a separate WS frame.
|
||||
"""
|
||||
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||
|
||||
def __init__(self, relay_init: bytes, proto_int: int):
|
||||
cipher = Cipher(algorithms.AES(relay_init[8:40]),
|
||||
modes.CTR(relay_init[40:56]))
|
||||
self._dec = cipher.encryptor()
|
||||
self._dec.update(ZERO_64)
|
||||
self._proto = proto_int
|
||||
self._cipher_buf = bytearray()
|
||||
self._plain_buf = bytearray()
|
||||
self._disabled = False
|
||||
|
||||
def split(self, chunk: bytes) -> List[bytes]:
|
||||
if not chunk:
|
||||
return []
|
||||
if self._disabled:
|
||||
return [chunk]
|
||||
|
||||
self._cipher_buf.extend(chunk)
|
||||
self._plain_buf.extend(self._dec.update(chunk))
|
||||
|
||||
parts = []
|
||||
offset = 0
|
||||
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:
|
||||
break
|
||||
if packet_len <= 0:
|
||||
parts.append(bytes(self._cipher_buf[offset:]))
|
||||
offset = buf_len
|
||||
self._disabled = True
|
||||
break
|
||||
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||
offset += packet_len
|
||||
|
||||
if offset:
|
||||
del self._cipher_buf[:offset]
|
||||
del self._plain_buf[:offset]
|
||||
return parts
|
||||
|
||||
def flush(self) -> List[bytes]:
|
||||
if not self._cipher_buf:
|
||||
return []
|
||||
tail = bytes(self._cipher_buf)
|
||||
self._cipher_buf.clear()
|
||||
self._plain_buf.clear()
|
||||
return [tail]
|
||||
|
||||
def _next_packet_len(self, offset: int, avail: int) -> Optional[int]:
|
||||
if avail <= 0:
|
||||
return None
|
||||
if self._proto == PROTO_ABRIDGED_INT:
|
||||
return self._next_abridged_len(offset, avail)
|
||||
if self._proto in (PROTO_INTERMEDIATE_INT,
|
||||
PROTO_PADDED_INTERMEDIATE_INT):
|
||||
return self._next_intermediate_len(offset, avail)
|
||||
return 0
|
||||
|
||||
def _next_abridged_len(self, offset: int, avail: int) -> Optional[int]:
|
||||
first = self._plain_buf[offset]
|
||||
if first in (0x7F, 0xFF):
|
||||
if avail < 4:
|
||||
return None
|
||||
payload_len = int.from_bytes(
|
||||
self._plain_buf[offset + 1:offset + 4], 'little') * 4
|
||||
header_len = 4
|
||||
else:
|
||||
payload_len = (first & 0x7F) * 4
|
||||
header_len = 1
|
||||
if payload_len <= 0:
|
||||
return 0
|
||||
packet_len = header_len + payload_len
|
||||
if avail < packet_len:
|
||||
return None
|
||||
return packet_len
|
||||
|
||||
def _next_intermediate_len(self, offset: int, avail: int) -> Optional[int]:
|
||||
if avail < 4:
|
||||
return None
|
||||
payload_len = _st_I_le.unpack_from(self._plain_buf, offset)[0] & 0x7FFFFFFF
|
||||
if payload_len <= 0:
|
||||
return 0
|
||||
packet_len = 4 + payload_len
|
||||
if avail < packet_len:
|
||||
return None
|
||||
return packet_len
|
||||
|
||||
|
||||
async def do_fallback(reader, writer, relay_init, label,
|
||||
dc: int, is_media: bool, media_tag: str,
|
||||
ctx: CryptoCtx, splitter=None):
|
||||
fallback_dst = DC_DEFAULT_IPS.get(dc)
|
||||
use_cf = proxy_config.fallback_cfproxy
|
||||
worker_domains = proxy_config.cfproxy_worker_domains
|
||||
|
||||
methods: List[str] = []
|
||||
|
||||
if worker_domains and fallback_dst:
|
||||
methods.append('cf_worker')
|
||||
if use_cf:
|
||||
methods.append('cf')
|
||||
if fallback_dst:
|
||||
methods.append('tcp')
|
||||
|
||||
for method in methods:
|
||||
if method == 'cf_worker' and fallback_dst:
|
||||
ok = await _cfproxy_worker_fallback(
|
||||
reader, writer, relay_init, label, ctx,
|
||||
dc=dc, is_media=is_media, fallback_dst=fallback_dst,
|
||||
splitter=splitter)
|
||||
if ok:
|
||||
return True
|
||||
elif method == 'cf':
|
||||
ok = await _cfproxy_fallback(
|
||||
reader, writer, relay_init, label, ctx,
|
||||
dc=dc, is_media=is_media,
|
||||
splitter=splitter)
|
||||
if ok:
|
||||
return True
|
||||
elif method == 'tcp' and fallback_dst:
|
||||
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
ok = await _tcp_fallback(
|
||||
reader, writer, fallback_dst, 443,
|
||||
relay_init, label, ctx)
|
||||
if ok:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
|
||||
ctx: CryptoCtx,
|
||||
dc: int, is_media: bool,
|
||||
fallback_dst: str,
|
||||
splitter=None):
|
||||
media_tag = ' media' if is_media else ''
|
||||
worker_domains = proxy_config.cfproxy_worker_domains
|
||||
if not worker_domains:
|
||||
return False
|
||||
|
||||
random.shuffle(worker_domains)
|
||||
|
||||
for worker_domain in worker_domains:
|
||||
ws = await cf_worker_pool.get(dc, worker_domain, fallback_dst)
|
||||
if ws:
|
||||
log.info("[%s] DC%d%s -> CF worker pool hit for %s",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
else:
|
||||
query = urlencode({
|
||||
'dst': fallback_dst,
|
||||
'dc': str(dc),
|
||||
})
|
||||
path = f'/apiws?{query}'
|
||||
|
||||
log.info("[%s] DC%d%s -> trying CF worker %s for %s",
|
||||
label, dc, media_tag, worker_domain, fallback_dst)
|
||||
|
||||
try:
|
||||
ws = await RawWebSocket.connect(worker_domain, worker_domain,
|
||||
timeout=10.0, path=path)
|
||||
except Exception as exc:
|
||||
log.warning("[%s] DC%d%s CF worker %s failed: %s",
|
||||
label, dc, media_tag, worker_domain, repr(exc))
|
||||
continue
|
||||
|
||||
stats.connections_cfproxy += 1
|
||||
await ws.send(relay_init)
|
||||
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||
dc=dc, is_media=is_media,
|
||||
splitter=splitter)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||
ctx: CryptoCtx,
|
||||
dc: int, is_media: bool,
|
||||
splitter=None):
|
||||
media_tag = ' media' if is_media else ''
|
||||
ws = None
|
||||
chosen_domain = None
|
||||
|
||||
log.info("[%s] DC%d%s -> trying CF proxy",
|
||||
label, dc, media_tag)
|
||||
|
||||
for base_domain in balancer.get_domains_for_dc(dc):
|
||||
domain = f'kws{dc}.{base_domain}'
|
||||
try:
|
||||
ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
|
||||
chosen_domain = base_domain
|
||||
break
|
||||
except Exception as exc:
|
||||
log.warning("[%s] DC%d%s CF proxy failed: %s",
|
||||
label, dc, media_tag, repr(exc))
|
||||
|
||||
if ws is None:
|
||||
return False
|
||||
|
||||
if chosen_domain and balancer.update_domain_for_dc(dc, chosen_domain):
|
||||
log.info("[%s] Switched active CF domain", label)
|
||||
|
||||
stats.connections_cfproxy += 1
|
||||
await ws.send(relay_init)
|
||||
await bridge_ws_reencrypt(reader, writer, ws, label, ctx,
|
||||
dc=dc, is_media=is_media,
|
||||
splitter=splitter)
|
||||
return True
|
||||
|
||||
|
||||
async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: CryptoCtx):
|
||||
try:
|
||||
rr, rw = await asyncio.wait_for(
|
||||
asyncio.open_connection(dst, port), timeout=10)
|
||||
except Exception as exc:
|
||||
log.warning("[%s] TCP fallback to %s:%d failed: %s",
|
||||
label, dst, port, repr(exc))
|
||||
return False
|
||||
|
||||
stats.connections_tcp_fallback += 1
|
||||
rw.write(relay_init)
|
||||
await rw.drain()
|
||||
await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, ctx)
|
||||
return True
|
||||
|
||||
|
||||
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
ctx: CryptoCtx,
|
||||
dc=None, is_media=False,
|
||||
splitter: Optional[MsgSplitter] = None):
|
||||
"""
|
||||
Bidirectional TCP(client) <-> WS(telegram) with re-encryption.
|
||||
client ciphertext → decrypt(clt_key) → encrypt(tg_key) → WS
|
||||
WS data → decrypt(tg_key) → encrypt(clt_key) → client TCP
|
||||
"""
|
||||
dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?"
|
||||
|
||||
up_bytes = 0
|
||||
down_bytes = 0
|
||||
up_packets = 0
|
||||
down_packets = 0
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
close_reason = 'normal'
|
||||
|
||||
async def tcp_to_ws():
|
||||
nonlocal up_bytes, up_packets, close_reason
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(65536)
|
||||
if not chunk:
|
||||
if splitter:
|
||||
tail = splitter.flush()
|
||||
if tail:
|
||||
await ws.send(tail[0])
|
||||
break
|
||||
n = len(chunk)
|
||||
stats.bytes_up += n
|
||||
up_bytes += n
|
||||
up_packets += 1
|
||||
plain = ctx.clt_dec.update(chunk)
|
||||
chunk = ctx.tg_enc.update(plain)
|
||||
if splitter:
|
||||
parts = splitter.split(chunk)
|
||||
if not parts:
|
||||
continue
|
||||
if len(parts) > 1:
|
||||
await ws.send_batch(parts)
|
||||
else:
|
||||
await ws.send(parts[0])
|
||||
else:
|
||||
await ws.send(chunk)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
close_reason = f"client: {type(e).__name__}"
|
||||
except Exception as e:
|
||||
close_reason = f"client: {type(e).__name__}: {e}"
|
||||
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||
|
||||
async def ws_to_tcp():
|
||||
nonlocal down_bytes, down_packets, close_reason
|
||||
try:
|
||||
while True:
|
||||
data = await ws.recv()
|
||||
if data is None:
|
||||
if close_reason == 'normal':
|
||||
close_reason = 'upstream: ws_close'
|
||||
break
|
||||
n = len(data)
|
||||
stats.bytes_down += n
|
||||
down_bytes += n
|
||||
down_packets += 1
|
||||
plain = ctx.tg_dec.update(data)
|
||||
data = ctx.clt_enc.update(plain)
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except asyncio.CancelledError:
|
||||
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:
|
||||
close_reason = f"upstream: {type(e).__name__}: {e}"
|
||||
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||
|
||||
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||
asyncio.create_task(ws_to_tcp())]
|
||||
try:
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except BaseException:
|
||||
pass
|
||||
elapsed = asyncio.get_running_loop().time() - start_time
|
||||
log.info("[%s] %s WS session closed (%s): "
|
||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||
label, dc_tag, close_reason,
|
||||
human_bytes(up_bytes), up_packets,
|
||||
human_bytes(down_bytes), down_packets,
|
||||
elapsed)
|
||||
try:
|
||||
await ws.close()
|
||||
except BaseException:
|
||||
pass
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
async def _bridge_tcp_reencrypt(reader, writer, remote_reader, remote_writer,
|
||||
label, ctx: CryptoCtx):
|
||||
"""Bidirectional TCP <-> TCP with re-encryption."""
|
||||
|
||||
async def forward(src, dst_w, is_up):
|
||||
try:
|
||||
while True:
|
||||
data = await src.read(65536)
|
||||
if not data:
|
||||
break
|
||||
n = len(data)
|
||||
if is_up:
|
||||
stats.bytes_up += n
|
||||
plain = ctx.clt_dec.update(data)
|
||||
data = ctx.tg_enc.update(plain)
|
||||
else:
|
||||
stats.bytes_down += n
|
||||
plain = ctx.tg_dec.update(data)
|
||||
data = ctx.clt_enc.update(plain)
|
||||
dst_w.write(data)
|
||||
await dst_w.drain()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug("[%s] forward ended: %s", label, e)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(forward(reader, remote_writer, True)),
|
||||
asyncio.create_task(forward(remote_reader, writer, False)),
|
||||
]
|
||||
try:
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except BaseException:
|
||||
pass
|
||||
for w in (writer, remote_writer):
|
||||
try:
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
except BaseException:
|
||||
pass
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import string
|
||||
import random
|
||||
import socket as _socket
|
||||
import threading
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
from urllib.request import Request
|
||||
|
||||
from .balancer import balancer
|
||||
from .utils import build_github_opener
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
CFPROXY_DOMAINS_URL = (
|
||||
"https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main"
|
||||
"/.github/cfproxy-domains.txt"
|
||||
)
|
||||
|
||||
_CFPROXY_ENC: List[str] = [
|
||||
'virkgj.com',
|
||||
'vmmzovy.com',
|
||||
'mkuosckvso.com',
|
||||
'zaewayzmplad.com',
|
||||
'twdmbzcm.com',
|
||||
'awzwsldi.com',
|
||||
'clngqrflngqin.com',
|
||||
'tjacxbqtj.com',
|
||||
'bxaxtxmrw.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))
|
||||
|
||||
|
||||
def _dd(s: str) -> str:
|
||||
"""Only for decoding CF proxy domains"""
|
||||
if not s[-4:] == '.com':
|
||||
return s
|
||||
p, n = s[:-4], sum(c.isalpha() for c in s[:-4])
|
||||
return ''.join(
|
||||
chr((ord(c) - (97 if c > '`' else 65) - n) % 26 + (97 if c > '`' else 65))
|
||||
if c.isalpha() else c for c in p
|
||||
) + _S
|
||||
|
||||
|
||||
CFPROXY_DEFAULT_DOMAINS: List[str] = [_dd(d) for d in _CFPROXY_ENC]
|
||||
_CFPROXY_MIN_VALID_DOMAINS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyConfig:
|
||||
port: int = 1443
|
||||
host: str = '127.0.0.1'
|
||||
secret: str = field(default_factory=lambda: os.urandom(16).hex())
|
||||
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
|
||||
buffer_size: int = 256 * 1024
|
||||
pool_size: int = 4
|
||||
fallback_cfproxy: bool = True
|
||||
cfproxy_user_domains: List[str] = field(default_factory=list)
|
||||
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||
fake_tls_domain: str = ''
|
||||
proxy_protocol: bool = False
|
||||
|
||||
|
||||
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]:
|
||||
try:
|
||||
req = Request(CFPROXY_DOMAINS_URL + "?" + "".join(random.choices(string.ascii_letters, k=7)),
|
||||
headers={'User-Agent': 'tg-ws-proxy'})
|
||||
with build_github_opener().open(req, timeout=10) as resp:
|
||||
text = resp.read().decode('utf-8', errors='replace')
|
||||
encoded = [
|
||||
line.strip() for line in text.splitlines()
|
||||
if line.strip() and not line.startswith('#')
|
||||
]
|
||||
return [_dd(d) for d in encoded]
|
||||
except Exception as exc:
|
||||
log.warning("Failed to fetch CF proxy domain list: %s", repr(exc))
|
||||
return []
|
||||
|
||||
|
||||
def _is_valid_domain(domain: str) -> bool:
|
||||
if not domain or len(domain) > 253:
|
||||
return False
|
||||
if domain.startswith('.') or domain.endswith('.'):
|
||||
return False
|
||||
labels = domain.split('.')
|
||||
if len(labels) < 2:
|
||||
return False
|
||||
for label in labels:
|
||||
if not label or len(label) > 63:
|
||||
return False
|
||||
if label[0] == '-' or label[-1] == '-':
|
||||
return False
|
||||
if not all(ch.isalnum() or ch == '-' for ch in label):
|
||||
return False
|
||||
# TLD should contain letters and be at least 2 chars.
|
||||
tld = labels[-1]
|
||||
if len(tld) < 2 or not any(ch.isalpha() for ch in tld):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_domain_pool(domains: List[str]) -> List[str]:
|
||||
seen = set()
|
||||
normalized: List[str] = []
|
||||
for domain in domains:
|
||||
item = domain.strip().lower()
|
||||
if not _is_valid_domain(item):
|
||||
continue
|
||||
if item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def refresh_cfproxy_domains() -> None:
|
||||
if proxy_config.cfproxy_user_domains:
|
||||
return
|
||||
|
||||
fetched = _fetch_cfproxy_domain_list()
|
||||
pool = _normalize_domain_pool(fetched)
|
||||
if len(pool) >= _CFPROXY_MIN_VALID_DOMAINS:
|
||||
balancer.update_domains_list(pool)
|
||||
log.info("CF proxy domain pool updated from GitHub (%d domains)", len(pool))
|
||||
return
|
||||
|
||||
if fetched:
|
||||
log.warning(
|
||||
"Ignoring fetched CF proxy domains due to low-quality payload "
|
||||
"(total=%d, valid=%d, required>=%d); keeping current domain pool",
|
||||
len(fetched), len(pool), _CFPROXY_MIN_VALID_DOMAINS,
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"CF proxy domain refresh failed or empty response; "
|
||||
"keeping current domain pool",
|
||||
)
|
||||
|
||||
|
||||
_refresh_stop: threading.Event = threading.Event()
|
||||
|
||||
|
||||
def start_cfproxy_domain_refresh() -> None:
|
||||
global _refresh_stop
|
||||
_refresh_stop.set()
|
||||
_refresh_stop = threading.Event()
|
||||
stop = _refresh_stop
|
||||
|
||||
balancer.update_domains_list(CFPROXY_DEFAULT_DOMAINS)
|
||||
|
||||
def _loop():
|
||||
refresh_cfproxy_domains()
|
||||
while not stop.wait(timeout=3600):
|
||||
refresh_cfproxy_domains()
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name='cfproxy-domains-refresh').start()
|
||||
|
||||
|
||||
def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]:
|
||||
dc_redirects: Dict[int, str] = {}
|
||||
for entry in dc_ip_list:
|
||||
if ':' not in entry:
|
||||
err = ValueError(
|
||||
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)
|
||||
try:
|
||||
dc_n = int(dc_s)
|
||||
_socket.inet_aton(ip_s)
|
||||
except (ValueError, OSError):
|
||||
err = ValueError(f"Invalid --dc-ip {entry!r}")
|
||||
err.entry = entry
|
||||
err.kind = "invalid"
|
||||
raise err
|
||||
dc_redirects[dc_n] = ip_s
|
||||
return dc_redirects
|
||||
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
_SBOX = (
|
||||
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B,
|
||||
0xFE, 0xD7, 0xAB, 0x76, 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0,
|
||||
0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, 0xB7, 0xFD, 0x93, 0x26,
|
||||
0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
|
||||
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2,
|
||||
0xEB, 0x27, 0xB2, 0x75, 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0,
|
||||
0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, 0x53, 0xD1, 0x00, 0xED,
|
||||
0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
|
||||
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F,
|
||||
0x50, 0x3C, 0x9F, 0xA8, 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5,
|
||||
0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, 0xCD, 0x0C, 0x13, 0xEC,
|
||||
0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
|
||||
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14,
|
||||
0xDE, 0x5E, 0x0B, 0xDB, 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C,
|
||||
0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, 0xE7, 0xC8, 0x37, 0x6D,
|
||||
0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
|
||||
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F,
|
||||
0x4B, 0xBD, 0x8B, 0x8A, 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E,
|
||||
0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, 0xE1, 0xF8, 0x98, 0x11,
|
||||
0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
|
||||
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F,
|
||||
0xB0, 0x54, 0xBB, 0x16,
|
||||
)
|
||||
_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
|
||||
|
||||
|
||||
class AesCtrTransform(Protocol):
|
||||
def update(self, data: bytes) -> bytes:
|
||||
...
|
||||
|
||||
def finalize(self) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
def _xtime(value: int) -> int:
|
||||
value <<= 1
|
||||
if value & 0x100:
|
||||
value ^= 0x11B
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
def _mul2(value: int) -> int:
|
||||
return _xtime(value)
|
||||
|
||||
|
||||
def _mul3(value: int) -> int:
|
||||
return _xtime(value) ^ value
|
||||
|
||||
|
||||
def _add_round_key(state: list[int], round_key: bytes):
|
||||
for idx in range(16):
|
||||
state[idx] ^= round_key[idx]
|
||||
|
||||
|
||||
def _sub_bytes(state: list[int]):
|
||||
for idx in range(16):
|
||||
state[idx] = _SBOX[state[idx]]
|
||||
|
||||
|
||||
def _shift_rows(state: list[int]):
|
||||
state[1], state[5], state[9], state[13] = (
|
||||
state[5], state[9], state[13], state[1]
|
||||
)
|
||||
state[2], state[6], state[10], state[14] = (
|
||||
state[10], state[14], state[2], state[6]
|
||||
)
|
||||
state[3], state[7], state[11], state[15] = (
|
||||
state[15], state[3], state[7], state[11]
|
||||
)
|
||||
|
||||
|
||||
def _mix_columns(state: list[int]):
|
||||
for offset in range(0, 16, 4):
|
||||
s0, s1, s2, s3 = state[offset:offset + 4]
|
||||
state[offset + 0] = _mul2(s0) ^ _mul3(s1) ^ s2 ^ s3
|
||||
state[offset + 1] = s0 ^ _mul2(s1) ^ _mul3(s2) ^ s3
|
||||
state[offset + 2] = s0 ^ s1 ^ _mul2(s2) ^ _mul3(s3)
|
||||
state[offset + 3] = _mul3(s0) ^ s1 ^ s2 ^ _mul2(s3)
|
||||
|
||||
|
||||
def _rot_word(word: list[int]) -> list[int]:
|
||||
return word[1:] + word[:1]
|
||||
|
||||
|
||||
def _sub_word(word: list[int]) -> list[int]:
|
||||
return [_SBOX[value] for value in word]
|
||||
|
||||
|
||||
def _expand_round_keys(key: bytes) -> tuple[list[bytes], int]:
|
||||
if len(key) not in (16, 24, 32):
|
||||
raise ValueError("AES key must be 16, 24, or 32 bytes long")
|
||||
|
||||
nk = len(key) // 4
|
||||
nr = {4: 10, 6: 12, 8: 14}[nk]
|
||||
words = [list(key[idx:idx + 4]) for idx in range(0, len(key), 4)]
|
||||
total_words = 4 * (nr + 1)
|
||||
|
||||
for idx in range(nk, total_words):
|
||||
temp = words[idx - 1][:]
|
||||
if idx % nk == 0:
|
||||
temp = _sub_word(_rot_word(temp))
|
||||
temp[0] ^= _RCON[idx // nk - 1]
|
||||
elif nk > 6 and idx % nk == 4:
|
||||
temp = _sub_word(temp)
|
||||
words.append([
|
||||
words[idx - nk][byte_idx] ^ temp[byte_idx]
|
||||
for byte_idx in range(4)
|
||||
])
|
||||
|
||||
round_keys = []
|
||||
for round_idx in range(nr + 1):
|
||||
start = round_idx * 4
|
||||
round_keys.append(bytes(sum(words[start:start + 4], [])))
|
||||
return round_keys, nr
|
||||
|
||||
|
||||
class _PurePythonAesCtrTransform:
|
||||
def __init__(self, key: bytes, iv: bytes):
|
||||
if len(iv) != 16:
|
||||
raise ValueError("AES-CTR IV must be 16 bytes long")
|
||||
self._round_keys, self._rounds = _expand_round_keys(key)
|
||||
self._counter = bytearray(iv)
|
||||
self._buffer = b""
|
||||
self._buffer_offset = 0
|
||||
|
||||
def update(self, data: bytes) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
|
||||
out = bytearray(len(data))
|
||||
data_offset = 0
|
||||
|
||||
while data_offset < len(data):
|
||||
if self._buffer_offset >= len(self._buffer):
|
||||
self._buffer = self._encrypt_block(bytes(self._counter))
|
||||
self._buffer_offset = 0
|
||||
self._increment_counter()
|
||||
|
||||
available = len(self._buffer) - self._buffer_offset
|
||||
chunk_size = min(len(data) - data_offset, available)
|
||||
for chunk_idx in range(chunk_size):
|
||||
out[data_offset + chunk_idx] = (
|
||||
data[data_offset + chunk_idx]
|
||||
^ self._buffer[self._buffer_offset + chunk_idx]
|
||||
)
|
||||
data_offset += chunk_size
|
||||
self._buffer_offset += chunk_size
|
||||
|
||||
return bytes(out)
|
||||
|
||||
def finalize(self) -> bytes:
|
||||
return b""
|
||||
|
||||
def _encrypt_block(self, block: bytes) -> bytes:
|
||||
state = list(block)
|
||||
_add_round_key(state, self._round_keys[0])
|
||||
|
||||
for round_idx in range(1, self._rounds):
|
||||
_sub_bytes(state)
|
||||
_shift_rows(state)
|
||||
_mix_columns(state)
|
||||
_add_round_key(state, self._round_keys[round_idx])
|
||||
|
||||
_sub_bytes(state)
|
||||
_shift_rows(state)
|
||||
_add_round_key(state, self._round_keys[self._rounds])
|
||||
return bytes(state)
|
||||
|
||||
def _increment_counter(self):
|
||||
for idx in range(15, -1, -1):
|
||||
self._counter[idx] = (self._counter[idx] + 1) & 0xFF
|
||||
if self._counter[idx] != 0:
|
||||
break
|
||||
|
||||
|
||||
def _create_cryptography_transform(key: bytes,
|
||||
iv: bytes) -> AesCtrTransform:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
|
||||
return cipher.encryptor()
|
||||
|
||||
|
||||
def create_aes_ctr_transform(key: bytes, iv: bytes,
|
||||
backend: str | None = None) -> AesCtrTransform:
|
||||
"""
|
||||
Create a stateful AES-CTR transform.
|
||||
|
||||
Windows keeps using `cryptography` by default. Android can select the
|
||||
pure-Python backend to avoid native build dependencies.
|
||||
"""
|
||||
selected = backend or os.environ.get(
|
||||
'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography')
|
||||
|
||||
if selected == 'cryptography':
|
||||
return _create_cryptography_transform(key, iv)
|
||||
|
||||
if selected == 'python':
|
||||
return _PurePythonAesCtrTransform(key, iv)
|
||||
|
||||
raise ValueError(f"Unsupported AES-CTR backend: {selected}")
|
||||
@@ -1,256 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from .stats import stats
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
TLS_RECORD_HANDSHAKE = 0x16
|
||||
TLS_RECORD_CCS = 0x14
|
||||
TLS_RECORD_APPDATA = 0x17
|
||||
|
||||
TLS_VERSION_10 = b'\x03\x01'
|
||||
TLS_VERSION_12 = b'\x03\x03'
|
||||
TLS_VERSION_13 = b'\x03\x04'
|
||||
|
||||
CLIENT_RANDOM_OFFSET = 11
|
||||
CLIENT_RANDOM_LEN = 32
|
||||
SESSION_ID_OFFSET = 44
|
||||
SESSION_ID_LEN = 32
|
||||
|
||||
TIMESTAMP_TOLERANCE = 120
|
||||
|
||||
TLS_APPDATA_MAX = 16384
|
||||
|
||||
|
||||
_CCS_FRAME = b'\x14\x03\x03\x00\x01\x01'
|
||||
|
||||
_SERVER_HELLO_TEMPLATE = bytearray(
|
||||
b'\x16\x03\x03\x00\x7a'
|
||||
b'\x02\x00\x00\x76'
|
||||
b'\x03\x03'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x20'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x13\x01\x00'
|
||||
+ b'\x00\x2e'
|
||||
+ b'\x00\x33\x00\x24\x00\x1d\x00\x20'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x00\x2b\x00\x02\x03\x04'
|
||||
)
|
||||
|
||||
_SH_RANDOM_OFF = 11
|
||||
_SH_SESSID_OFF = 44
|
||||
_SH_PUBKEY_OFF = 89
|
||||
|
||||
|
||||
def verify_client_hello(data: bytes, secret: bytes) -> Optional[Tuple[bytes, bytes, int]]:
|
||||
n = len(data)
|
||||
# 5 (record hdr) + 6 (hs type+len+version) + 32 (random) = 43
|
||||
if n < 43:
|
||||
return None
|
||||
if data[0] != TLS_RECORD_HANDSHAKE:
|
||||
return None
|
||||
if data[5] != 0x01:
|
||||
return None
|
||||
|
||||
client_random = bytes(data[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN])
|
||||
|
||||
zeroed = bytearray(data)
|
||||
zeroed[CLIENT_RANDOM_OFFSET:CLIENT_RANDOM_OFFSET + CLIENT_RANDOM_LEN] = b'\x00' * CLIENT_RANDOM_LEN
|
||||
|
||||
expected = hmac.new(secret, bytes(zeroed), hashlib.sha256).digest()
|
||||
|
||||
if not hmac.compare_digest(expected[:28], client_random[:28]):
|
||||
return None
|
||||
|
||||
ts_xor = bytes(client_random[28 + i] ^ expected[28 + i] for i in range(4))
|
||||
timestamp = struct.unpack('<I', ts_xor)[0]
|
||||
|
||||
now = int(time.time())
|
||||
if abs(now - timestamp) > TIMESTAMP_TOLERANCE:
|
||||
return None
|
||||
|
||||
session_id = b'\x00' * SESSION_ID_LEN
|
||||
if n >= SESSION_ID_OFFSET + SESSION_ID_LEN and data[43] == 0x20:
|
||||
session_id = bytes(data[SESSION_ID_OFFSET:SESSION_ID_OFFSET + SESSION_ID_LEN])
|
||||
|
||||
return client_random, session_id, timestamp
|
||||
|
||||
|
||||
def build_server_hello(secret: bytes, client_random: bytes, session_id: bytes) -> bytes:
|
||||
sh = bytearray(_SERVER_HELLO_TEMPLATE)
|
||||
sh[_SH_SESSID_OFF:_SH_SESSID_OFF + 32] = session_id
|
||||
sh[_SH_PUBKEY_OFF:_SH_PUBKEY_OFF + 32] = os.urandom(32)
|
||||
|
||||
ccs = _CCS_FRAME
|
||||
encrypted_size = random.randint(1900, 2100)
|
||||
encrypted_data = os.urandom(encrypted_size)
|
||||
app_record = b'\x17\x03\x03' + struct.pack('>H', encrypted_size) + encrypted_data
|
||||
|
||||
response = bytes(sh) + ccs + app_record
|
||||
|
||||
hmac_input = client_random + response
|
||||
server_random = hmac.new(secret, hmac_input, hashlib.sha256).digest()
|
||||
|
||||
final = bytearray(response)
|
||||
final[_SH_RANDOM_OFF:_SH_RANDOM_OFF + 32] = server_random
|
||||
|
||||
return bytes(final)
|
||||
|
||||
|
||||
def wrap_tls_record(data: bytes) -> bytes:
|
||||
parts = []
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset:offset + TLS_APPDATA_MAX]
|
||||
parts.append(
|
||||
b'\x17\x03\x03'
|
||||
+ struct.pack('>H', len(chunk))
|
||||
+ chunk
|
||||
)
|
||||
offset += len(chunk)
|
||||
return b''.join(parts)
|
||||
|
||||
|
||||
class FakeTlsStream:
|
||||
__slots__ = ('_reader', '_writer', '_read_buf', '_read_left')
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
self._read_buf = bytearray()
|
||||
self._read_left = 0
|
||||
|
||||
async def readexactly(self, n: int) -> bytes:
|
||||
while len(self._read_buf) < n:
|
||||
payload = await self._read_tls_payload()
|
||||
if not payload:
|
||||
raise asyncio.IncompleteReadError(bytes(self._read_buf), n)
|
||||
self._read_buf.extend(payload)
|
||||
result = bytes(self._read_buf[:n])
|
||||
del self._read_buf[:n]
|
||||
return result
|
||||
|
||||
async def read(self, n: int) -> bytes:
|
||||
if self._read_buf:
|
||||
chunk = bytes(self._read_buf[:n])
|
||||
del self._read_buf[:n]
|
||||
return chunk
|
||||
payload = await self._read_tls_payload()
|
||||
if not payload:
|
||||
return b''
|
||||
if len(payload) > n:
|
||||
self._read_buf.extend(payload[n:])
|
||||
return payload[:n]
|
||||
return payload
|
||||
|
||||
async def _read_tls_payload(self) -> bytes:
|
||||
if self._read_left > 0:
|
||||
data = await self._reader.read(self._read_left)
|
||||
if not data:
|
||||
return b''
|
||||
self._read_left -= len(data)
|
||||
return data
|
||||
|
||||
while True:
|
||||
hdr = await self._reader.readexactly(5)
|
||||
rtype = hdr[0]
|
||||
rec_len = struct.unpack('>H', hdr[3:5])[0]
|
||||
|
||||
if rtype == TLS_RECORD_CCS:
|
||||
if rec_len > 0:
|
||||
await self._reader.readexactly(rec_len)
|
||||
continue
|
||||
|
||||
if rtype != TLS_RECORD_APPDATA:
|
||||
return b''
|
||||
|
||||
data = await self._reader.read(min(rec_len, 65536))
|
||||
if not data:
|
||||
return b''
|
||||
remaining = rec_len - len(data)
|
||||
if remaining > 0:
|
||||
self._read_left = remaining
|
||||
return data
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._writer.write(wrap_tls_record(data))
|
||||
|
||||
async def drain(self) -> None:
|
||||
await self._writer.drain()
|
||||
|
||||
def close(self) -> None:
|
||||
self._writer.close()
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
await self._writer.wait_closed()
|
||||
|
||||
def get_extra_info(self, name, default=None):
|
||||
return self._writer.get_extra_info(name, default)
|
||||
|
||||
@property
|
||||
def transport(self):
|
||||
return self._writer.transport
|
||||
|
||||
def is_closing(self):
|
||||
return self._writer.is_closing()
|
||||
|
||||
|
||||
async def proxy_to_masking_domain(reader, writer, initial_data: bytes,
|
||||
domain: str, label: str) -> None:
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(domain, 443), timeout=10)
|
||||
except Exception as exc:
|
||||
log.warning("[%s] masking: cannot connect to %s:443: %s",
|
||||
label, domain, repr(exc))
|
||||
return
|
||||
|
||||
log.debug("[%s] masking -> %s:443", label, domain)
|
||||
stats.connections_masked += 1
|
||||
|
||||
try:
|
||||
if initial_data:
|
||||
up_writer.write(initial_data)
|
||||
await up_writer.drain()
|
||||
|
||||
async def _relay(src, dst):
|
||||
try:
|
||||
while True:
|
||||
chunk = await src.read(16384)
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
await dst.drain()
|
||||
except (ConnectionResetError, BrokenPipeError, OSError,
|
||||
asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
dst.close()
|
||||
await dst.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
_relay(reader, up_writer),
|
||||
_relay(up_reader, writer),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
up_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
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()
|
||||
@@ -1,267 +0,0 @@
|
||||
import os
|
||||
import ssl
|
||||
import logging
|
||||
import base64
|
||||
import struct
|
||||
import asyncio
|
||||
import socket as _socket
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from .config import proxy_config
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
_st_BB = struct.Struct('>BB')
|
||||
_st_BBH = struct.Struct('>BBH')
|
||||
_st_BBQ = struct.Struct('>BBQ')
|
||||
_st_BB4s = struct.Struct('>BB4s')
|
||||
_st_BBH4s = struct.Struct('>BBH4s')
|
||||
_st_BBQ4s = struct.Struct('>BBQ4s')
|
||||
_st_H = struct.Struct('>H')
|
||||
_st_Q = struct.Struct('>Q')
|
||||
|
||||
_ssl_ctx = ssl.create_default_context()
|
||||
_ssl_ctx.check_hostname = False
|
||||
_ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
class WsHandshakeError(Exception):
|
||||
def __init__(self, status_code: int, status_line: str,
|
||||
headers: Optional[dict] = None, location: Optional[str] = None):
|
||||
self.status_code = status_code
|
||||
self.status_line = status_line
|
||||
self.headers = headers or {}
|
||||
self.location = location
|
||||
super().__init__(f"HTTP {status_code}: {status_line}")
|
||||
|
||||
@property
|
||||
def is_redirect(self) -> bool:
|
||||
return self.status_code in (301, 302, 303, 307, 308)
|
||||
|
||||
|
||||
def _xor_mask(data: bytes, mask: bytes) -> bytes:
|
||||
if not data:
|
||||
return data
|
||||
n = len(data)
|
||||
mask_rep = (mask * (n // 4 + 1))[:n]
|
||||
return (int.from_bytes(data, 'big') ^
|
||||
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
||||
|
||||
|
||||
def set_sock_opts(transport, buffer_size):
|
||||
sock = transport.get_extra_info('socket')
|
||||
if sock is None:
|
||||
return
|
||||
|
||||
try:
|
||||
sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, buffer_size)
|
||||
sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, buffer_size)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class RawWebSocket:
|
||||
__slots__ = ('reader', 'writer', '_closed')
|
||||
|
||||
OP_BINARY = 0x2
|
||||
OP_CLOSE = 0x8
|
||||
OP_PING = 0x9
|
||||
OP_PONG = 0xA
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter):
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self._closed = False
|
||||
|
||||
@staticmethod
|
||||
async def connect(host: str, domain: str, timeout: float = 10.0,
|
||||
path: str = '/apiws', *,
|
||||
sni: Optional[str] = None) -> 'RawWebSocket':
|
||||
if sni is None:
|
||||
sni = domain
|
||||
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
|
||||
server_hostname=sni),
|
||||
timeout=min(timeout, 10))
|
||||
|
||||
set_sock_opts(writer.transport, proxy_config.buffer_size)
|
||||
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
|
||||
req = (
|
||||
f'GET {path} HTTP/1.1\r\n'
|
||||
f'Host: {domain}\r\n'
|
||||
f'Upgrade: websocket\r\n'
|
||||
f'Connection: Upgrade\r\n'
|
||||
f'Sec-WebSocket-Key: {ws_key}\r\n'
|
||||
f'Sec-WebSocket-Version: 13\r\n'
|
||||
f'Sec-WebSocket-Protocol: binary\r\n'
|
||||
f'\r\n'
|
||||
)
|
||||
|
||||
writer.write(req.encode())
|
||||
await writer.drain()
|
||||
|
||||
response_lines: list[str] = []
|
||||
try:
|
||||
while True:
|
||||
line = await asyncio.wait_for(reader.readline(),
|
||||
timeout=timeout)
|
||||
if line in (b'\r\n', b'\n', b''):
|
||||
break
|
||||
response_lines.append(
|
||||
line.decode('utf-8', errors='replace').strip())
|
||||
except asyncio.TimeoutError:
|
||||
writer.close()
|
||||
raise
|
||||
|
||||
if not response_lines:
|
||||
writer.close()
|
||||
raise WsHandshakeError(0, 'empty response')
|
||||
|
||||
first_line = response_lines[0]
|
||||
parts = first_line.split(' ', 2)
|
||||
try:
|
||||
status_code = int(parts[1]) if len(parts) >= 2 else 0
|
||||
except ValueError:
|
||||
status_code = 0
|
||||
|
||||
if status_code == 101:
|
||||
return RawWebSocket(reader, writer)
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
for hl in response_lines[1:]:
|
||||
if ':' in hl:
|
||||
k, v = hl.split(':', 1)
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
|
||||
writer.close()
|
||||
raise WsHandshakeError(status_code, first_line, headers,
|
||||
location=headers.get('location'))
|
||||
|
||||
async def send(self, data: bytes):
|
||||
if self._closed:
|
||||
raise ConnectionError("WebSocket closed")
|
||||
frame = self._build_frame(self.OP_BINARY, data, mask=True)
|
||||
self.writer.write(frame)
|
||||
await self.writer.drain()
|
||||
|
||||
async def send_batch(self, parts: List[bytes]):
|
||||
if self._closed:
|
||||
raise ConnectionError("WebSocket closed")
|
||||
for part in parts:
|
||||
self.writer.write(
|
||||
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||
await self.writer.drain()
|
||||
|
||||
async def recv(self) -> Optional[bytes]:
|
||||
while not self._closed:
|
||||
opcode, payload = await self._read_frame()
|
||||
|
||||
if opcode == self.OP_CLOSE:
|
||||
self._closed = True
|
||||
code, reason = self._parse_close(payload)
|
||||
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||
code, reason)
|
||||
try:
|
||||
self.writer.write(self._build_frame(
|
||||
self.OP_CLOSE,
|
||||
payload[:2] if payload else b'', mask=True))
|
||||
await self.writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
if opcode == self.OP_PING:
|
||||
try:
|
||||
self.writer.write(
|
||||
self._build_frame(self.OP_PONG, payload, mask=True))
|
||||
await self.writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
if opcode == self.OP_PONG:
|
||||
continue
|
||||
|
||||
if opcode in (0x1, 0x2):
|
||||
return payload
|
||||
continue
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self.writer.write(
|
||||
self._build_frame(self.OP_CLOSE, b'', mask=True))
|
||||
await self.writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
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
|
||||
def _build_frame(opcode: int, data: bytes,
|
||||
mask: bool = False) -> bytes:
|
||||
length = len(data)
|
||||
fb = 0x80 | opcode
|
||||
if not mask:
|
||||
if length < 126:
|
||||
return _st_BB.pack(fb, length) + data
|
||||
if length < 65536:
|
||||
return _st_BBH.pack(fb, 126, length) + data
|
||||
return _st_BBQ.pack(fb, 127, length) + data
|
||||
mask_key = os.urandom(4)
|
||||
masked = _xor_mask(data, mask_key)
|
||||
if length < 126:
|
||||
return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked
|
||||
if length < 65536:
|
||||
return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked
|
||||
return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked
|
||||
|
||||
async def _read_frame(self) -> Tuple[int, bytes]:
|
||||
hdr = await self.reader.readexactly(2)
|
||||
opcode = hdr[0] & 0x0F
|
||||
length = hdr[1] & 0x7F
|
||||
if length == 126:
|
||||
length = _st_H.unpack(await self.reader.readexactly(2))[0]
|
||||
elif length == 127:
|
||||
length = _st_Q.unpack(await self.reader.readexactly(8))[0]
|
||||
if hdr[1] & 0x80:
|
||||
mask_key = await self.reader.readexactly(4)
|
||||
payload = await self.reader.readexactly(length)
|
||||
return opcode, _xor_mask(payload, mask_key)
|
||||
payload = await self.reader.readexactly(length)
|
||||
return opcode, payload
|
||||
@@ -1,43 +0,0 @@
|
||||
from .utils import human_bytes
|
||||
|
||||
class _Stats:
|
||||
def __init__(self):
|
||||
self.connections_total = 0
|
||||
self.connections_active = 0
|
||||
self.connections_ws = 0
|
||||
self.connections_tcp_fallback = 0
|
||||
self.connections_cfproxy = 0
|
||||
self.connections_fronting = 0
|
||||
self.connections_bad = 0
|
||||
self.connections_masked = 0
|
||||
self.ws_errors = 0
|
||||
self.bytes_up = 0
|
||||
self.bytes_down = 0
|
||||
self.pool_hits = 0
|
||||
self.pool_misses = 0
|
||||
self.cf_pool_hits = 0
|
||||
self.cf_pool_misses = 0
|
||||
|
||||
def summary(self) -> str:
|
||||
pool_total = self.pool_hits + self.pool_misses
|
||||
pool_s = (f"{self.pool_hits}/{pool_total}"
|
||||
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} "
|
||||
f"active={self.connections_active} "
|
||||
f"ws={self.connections_ws} "
|
||||
f"tcp_fb={self.connections_tcp_fallback} "
|
||||
f"cf={self.connections_cfproxy} "
|
||||
f"front={self.connections_fronting} "
|
||||
f"bad={self.connections_bad} "
|
||||
f"masked={self.connections_masked} "
|
||||
f"err={self.ws_errors} "
|
||||
f"pool={pool_s} "
|
||||
f"cf_pool={cf_pool_s} "
|
||||
f"up={human_bytes(self.bytes_up)} "
|
||||
f"down={human_bytes(self.bytes_down)}")
|
||||
|
||||
|
||||
stats = _Stats()
|
||||
+907
-425
File diff suppressed because it is too large
Load Diff
-104
@@ -1,104 +0,0 @@
|
||||
import socket as _socket
|
||||
import urllib.request
|
||||
import http.client
|
||||
|
||||
from typing import Optional, Dict, List
|
||||
from urllib.request import Request
|
||||
|
||||
|
||||
ZERO_64 = b'\x00' * 64
|
||||
HANDSHAKE_LEN = 64
|
||||
SKIP_LEN = 8
|
||||
PREKEY_LEN = 32
|
||||
KEY_LEN = 32
|
||||
IV_LEN = 16
|
||||
PROTO_TAG_POS = 56
|
||||
DC_IDX_POS = 60
|
||||
|
||||
PROTO_TAG_ABRIDGED = b'\xef\xef\xef\xef'
|
||||
PROTO_TAG_INTERMEDIATE = b'\xee\xee\xee\xee'
|
||||
PROTO_TAG_SECURE = b'\xdd\xdd\xdd\xdd'
|
||||
|
||||
PROTO_ABRIDGED_INT = 0xEFEFEFEF
|
||||
PROTO_INTERMEDIATE_INT = 0xEEEEEEEE
|
||||
PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
||||
|
||||
RESERVED_FIRST_BYTES = {0xEF}
|
||||
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
||||
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
||||
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
||||
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||
|
||||
_GITHUB_IPS: Dict[str, str] = {
|
||||
"release-assets.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:
|
||||
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||
if abs(n) < 1024:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024 # type: ignore
|
||||
return f"{n:.1f}TB"
|
||||
|
||||
|
||||
def get_link_host(host: str) -> Optional[str]:
|
||||
if host == '0.0.0.0':
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s:
|
||||
_s.connect(('8.8.8.8', 80))
|
||||
link_host = _s.getsockname()[0]
|
||||
except OSError:
|
||||
link_host = '127.0.0.1'
|
||||
return link_host
|
||||
else:
|
||||
return host
|
||||
|
||||
|
||||
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req: Request):
|
||||
host = req.host.split(":")[0]
|
||||
ip = _GITHUB_IPS.get(host)
|
||||
if not ip:
|
||||
return super().https_open(req)
|
||||
pinned = ip
|
||||
|
||||
class _Conn(http.client.HTTPSConnection):
|
||||
def connect(self):
|
||||
self.sock = _socket.create_connection(
|
||||
(pinned, self.port or 443),
|
||||
self.timeout,
|
||||
self.source_address,
|
||||
)
|
||||
if self._tunnel_host:
|
||||
self._tunnel()
|
||||
self.sock = self._context.wrap_socket(
|
||||
self.sock, server_hostname=self._tunnel_host or self.host
|
||||
)
|
||||
|
||||
try:
|
||||
return self.do_open(_Conn, req)
|
||||
except Exception:
|
||||
return super().https_open(req)
|
||||
|
||||
|
||||
def build_github_opener() -> urllib.request.OpenerDirector:
|
||||
return urllib.request.build_opener(_PinnedHTTPSHandler())
|
||||
+1
-4
@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
|
||||
dynamic=["version"]
|
||||
|
||||
description = "Telegram Desktop WebSocket Bridge Proxy"
|
||||
readme = "docs/README.md"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
license = { name = "MIT", file = "LICENSE" }
|
||||
@@ -71,6 +71,3 @@ packages = ["proxy", "ui", "utils"]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "proxy/__init__.py"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["F403", "F405"]
|
||||
@@ -0,0 +1,264 @@
|
||||
import sys
|
||||
import unittest
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
sys.path.insert(0, str(
|
||||
Path(__file__).resolve().parents[1] / "android" / "app" / "src" / "main" / "python"
|
||||
))
|
||||
|
||||
import android_proxy_bridge # noqa: E402
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy # noqa: E402
|
||||
|
||||
|
||||
class FakeJavaArrayList:
|
||||
def __init__(self, items):
|
||||
self._items = list(items)
|
||||
|
||||
def size(self):
|
||||
return len(self._items)
|
||||
|
||||
def get(self, index):
|
||||
return self._items[index]
|
||||
|
||||
|
||||
class AndroidProxyBridgeTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
tg_ws_proxy.reset_stats()
|
||||
android_proxy_bridge._LAST_ERROR = None
|
||||
|
||||
def test_normalize_dc_ip_list_with_python_iterable(self):
|
||||
result = android_proxy_bridge._normalize_dc_ip_list([
|
||||
"2:149.154.167.220",
|
||||
" ",
|
||||
"4:149.154.167.220 ",
|
||||
])
|
||||
|
||||
self.assertEqual(result, [
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220",
|
||||
])
|
||||
|
||||
def test_get_runtime_stats_json_reports_proxy_counters(self):
|
||||
tg_ws_proxy.reset_stats()
|
||||
snapshot = tg_ws_proxy.get_stats_snapshot()
|
||||
snapshot["bytes_up"] = 1536
|
||||
snapshot["bytes_down"] = 4096
|
||||
tg_ws_proxy._stats.bytes_up = snapshot["bytes_up"]
|
||||
tg_ws_proxy._stats.bytes_down = snapshot["bytes_down"]
|
||||
|
||||
result = json.loads(android_proxy_bridge.get_runtime_stats_json())
|
||||
|
||||
self.assertEqual(result["bytes_up"], 1536)
|
||||
self.assertEqual(result["bytes_down"], 4096)
|
||||
self.assertFalse(result["running"])
|
||||
self.assertIsNone(result["last_error"])
|
||||
|
||||
def test_get_runtime_stats_json_includes_last_error(self):
|
||||
android_proxy_bridge._LAST_ERROR = "boom"
|
||||
|
||||
result = json.loads(android_proxy_bridge.get_runtime_stats_json())
|
||||
|
||||
self.assertEqual(result["last_error"], "boom")
|
||||
|
||||
def test_normalize_dc_ip_list_with_java_array_list_shape(self):
|
||||
result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220",
|
||||
]))
|
||||
|
||||
self.assertEqual(result, [
|
||||
"2:149.154.167.220",
|
||||
"4:149.154.167.220",
|
||||
])
|
||||
|
||||
def test_start_proxy_saves_advanced_runtime_config(self):
|
||||
captured = {}
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *args, **kwargs):
|
||||
captured["runtime_init"] = kwargs
|
||||
self.log_file = Path("/tmp/proxy.log")
|
||||
|
||||
def reset_log_file(self):
|
||||
captured["reset_log_file"] = True
|
||||
|
||||
def setup_logging(self, verbose=False, log_max_mb=5):
|
||||
captured["verbose"] = verbose
|
||||
captured["log_max_mb"] = log_max_mb
|
||||
|
||||
def save_config(self, config):
|
||||
captured["config"] = dict(config)
|
||||
|
||||
def start_proxy(self, config):
|
||||
captured["start_proxy"] = dict(config)
|
||||
return True
|
||||
|
||||
def is_proxy_running(self):
|
||||
return True
|
||||
|
||||
def stop_proxy(self):
|
||||
captured["stop_proxy"] = True
|
||||
|
||||
original_runtime = android_proxy_bridge.ProxyAppRuntime
|
||||
try:
|
||||
android_proxy_bridge.ProxyAppRuntime = FakeRuntime
|
||||
log_path = android_proxy_bridge.start_proxy(
|
||||
"/tmp/app",
|
||||
"127.0.0.1",
|
||||
1443,
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
["2:149.154.167.220"],
|
||||
7.0,
|
||||
512,
|
||||
6,
|
||||
True,
|
||||
)
|
||||
finally:
|
||||
android_proxy_bridge.ProxyAppRuntime = original_runtime
|
||||
|
||||
self.assertEqual(log_path, "/tmp/proxy.log")
|
||||
self.assertEqual(captured["config"]["secret"], "0123456789abcdef0123456789abcdef")
|
||||
self.assertEqual(captured["config"]["log_max_mb"], 7.0)
|
||||
self.assertEqual(captured["config"]["buf_kb"], 512)
|
||||
self.assertEqual(captured["config"]["pool_size"], 6)
|
||||
self.assertEqual(captured["log_max_mb"], 7.0)
|
||||
self.assertTrue(captured["verbose"])
|
||||
|
||||
def test_get_update_status_json_merges_python_update_state(self):
|
||||
original_load_update_check = android_proxy_bridge._load_update_check
|
||||
try:
|
||||
captured = {}
|
||||
|
||||
class FakeUpdateCheck:
|
||||
RELEASES_PAGE_URL = "https://example.com/releases/latest"
|
||||
|
||||
@staticmethod
|
||||
def run_check(version):
|
||||
captured["run_check_version"] = version
|
||||
|
||||
@staticmethod
|
||||
def get_status():
|
||||
return {
|
||||
"checked": True,
|
||||
"latest": "1.3.1",
|
||||
"has_update": True,
|
||||
"ahead_of_release": False,
|
||||
"html_url": "https://example.com/release",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
|
||||
result = json.loads(android_proxy_bridge.get_update_status_json(True))
|
||||
finally:
|
||||
android_proxy_bridge._load_update_check = original_load_update_check
|
||||
|
||||
self.assertEqual(captured["run_check_version"], android_proxy_bridge.__version__)
|
||||
self.assertEqual(result["current_version"], android_proxy_bridge.__version__)
|
||||
self.assertEqual(result["latest"], "1.3.1")
|
||||
self.assertTrue(result["has_update"])
|
||||
self.assertTrue(result["checked"])
|
||||
self.assertEqual(result["html_url"], "https://example.com/release")
|
||||
|
||||
def test_get_update_status_json_reports_unchecked_state(self):
|
||||
original_load_update_check = android_proxy_bridge._load_update_check
|
||||
try:
|
||||
class FakeUpdateCheck:
|
||||
RELEASES_PAGE_URL = "https://example.com/releases/latest"
|
||||
|
||||
@staticmethod
|
||||
def get_status():
|
||||
return {
|
||||
"checked": False,
|
||||
"latest": "",
|
||||
"has_update": False,
|
||||
"ahead_of_release": False,
|
||||
"html_url": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
|
||||
result = json.loads(android_proxy_bridge.get_update_status_json(False))
|
||||
finally:
|
||||
android_proxy_bridge._load_update_check = original_load_update_check
|
||||
|
||||
self.assertFalse(result["checked"])
|
||||
self.assertEqual(result["current_version"], android_proxy_bridge.__version__)
|
||||
|
||||
def test_get_update_status_json_reports_import_error_without_breaking_bridge(self):
|
||||
original_load_update_check = android_proxy_bridge._load_update_check
|
||||
try:
|
||||
def fail():
|
||||
raise ModuleNotFoundError("No module named 'utils'")
|
||||
|
||||
android_proxy_bridge._load_update_check = fail
|
||||
result = json.loads(android_proxy_bridge.get_update_status_json(True))
|
||||
finally:
|
||||
android_proxy_bridge._load_update_check = original_load_update_check
|
||||
|
||||
self.assertFalse(result["checked"])
|
||||
self.assertIn("No module named 'utils'", result["error"])
|
||||
|
||||
def test_get_update_status_json_normalizes_none_fields_for_kotlin(self):
|
||||
original_load_update_check = android_proxy_bridge._load_update_check
|
||||
try:
|
||||
class FakeUpdateCheck:
|
||||
RELEASES_PAGE_URL = "https://example.com/releases/latest"
|
||||
|
||||
@staticmethod
|
||||
def get_status():
|
||||
return {
|
||||
"checked": True,
|
||||
"latest": None,
|
||||
"has_update": False,
|
||||
"ahead_of_release": True,
|
||||
"html_url": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
|
||||
result = json.loads(android_proxy_bridge.get_update_status_json(False))
|
||||
finally:
|
||||
android_proxy_bridge._load_update_check = original_load_update_check
|
||||
|
||||
self.assertEqual(result["latest"], "")
|
||||
self.assertEqual(result["error"], "")
|
||||
self.assertEqual(result["html_url"], "https://example.com/releases/latest")
|
||||
|
||||
def test_android_bridge_import_and_update_status_work_without_cryptography(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
script = f"""
|
||||
import importlib.abc
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path({str(root)!r})
|
||||
sys.path.insert(0, str(root / "android" / "app" / "src" / "main" / "python"))
|
||||
sys.path.insert(0, str(root))
|
||||
|
||||
class BlockCryptography(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname == "cryptography" or fullname.startswith("cryptography."):
|
||||
raise ModuleNotFoundError("No module named 'cryptography'")
|
||||
return None
|
||||
|
||||
sys.meta_path.insert(0, BlockCryptography())
|
||||
|
||||
import android_proxy_bridge
|
||||
print(android_proxy_bridge.get_update_status_json(False))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(result.stdout.strip())
|
||||
self.assertEqual(payload["current_version"], android_proxy_bridge.__version__)
|
||||
self.assertEqual(payload["error"], "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime
|
||||
|
||||
|
||||
class _FakeThread:
|
||||
def __init__(self, target=None, args=(), daemon=None, name=None):
|
||||
self.target = target
|
||||
self.args = args
|
||||
self.daemon = daemon
|
||||
self.name = name
|
||||
self.started = False
|
||||
self.join_timeout = None
|
||||
self._alive = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
self._alive = True
|
||||
|
||||
def is_alive(self):
|
||||
return self._alive
|
||||
|
||||
def join(self, timeout=None):
|
||||
self.join_timeout = timeout
|
||||
self._alive = False
|
||||
|
||||
|
||||
class ProxyAppRuntimeTests(unittest.TestCase):
|
||||
def test_load_config_returns_defaults_when_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
runtime = ProxyAppRuntime(Path(tmpdir))
|
||||
|
||||
cfg = runtime.load_config()
|
||||
|
||||
self.assertEqual(cfg, DEFAULT_CONFIG)
|
||||
|
||||
def test_load_config_merges_defaults_into_saved_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
app_dir = Path(tmpdir)
|
||||
config_path = app_dir / "config.json"
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
json.dumps({"port": 9050, "host": "127.0.0.2"}),
|
||||
encoding="utf-8")
|
||||
runtime = ProxyAppRuntime(app_dir)
|
||||
|
||||
cfg = runtime.load_config()
|
||||
|
||||
self.assertEqual(cfg["port"], 9050)
|
||||
self.assertEqual(cfg["host"], "127.0.0.2")
|
||||
self.assertEqual(cfg["dc_ip"], DEFAULT_CONFIG["dc_ip"])
|
||||
self.assertEqual(cfg["verbose"], DEFAULT_CONFIG["verbose"])
|
||||
|
||||
def test_invalid_config_file_falls_back_to_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
app_dir = Path(tmpdir)
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
(app_dir / "config.json").write_text("{broken", encoding="utf-8")
|
||||
runtime = ProxyAppRuntime(app_dir)
|
||||
|
||||
cfg = runtime.load_config()
|
||||
|
||||
self.assertEqual(cfg, DEFAULT_CONFIG)
|
||||
|
||||
def test_start_proxy_starts_thread_with_parsed_dc_options(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
captured = {}
|
||||
thread_holder = {}
|
||||
|
||||
def fake_parse(entries):
|
||||
captured["dc_ip"] = list(entries)
|
||||
return {2: "149.154.167.220"}
|
||||
|
||||
def fake_thread_factory(**kwargs):
|
||||
thread = _FakeThread(**kwargs)
|
||||
thread_holder["thread"] = thread
|
||||
return thread
|
||||
|
||||
runtime = ProxyAppRuntime(
|
||||
Path(tmpdir),
|
||||
parse_dc_ip_list=fake_parse,
|
||||
thread_factory=fake_thread_factory)
|
||||
|
||||
started = runtime.start_proxy(dict(DEFAULT_CONFIG))
|
||||
|
||||
self.assertTrue(started)
|
||||
self.assertEqual(captured["dc_ip"], DEFAULT_CONFIG["dc_ip"])
|
||||
self.assertTrue(thread_holder["thread"].started)
|
||||
self.assertEqual(
|
||||
thread_holder["thread"].args,
|
||||
(DEFAULT_CONFIG["port"], {2: "149.154.167.220"},
|
||||
DEFAULT_CONFIG["host"]))
|
||||
|
||||
def test_start_proxy_reports_bad_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
errors = []
|
||||
|
||||
def fake_parse(entries):
|
||||
raise ValueError("bad dc mapping")
|
||||
|
||||
runtime = ProxyAppRuntime(
|
||||
Path(tmpdir),
|
||||
parse_dc_ip_list=fake_parse,
|
||||
on_error=errors.append)
|
||||
|
||||
started = runtime.start_proxy({
|
||||
"host": "127.0.0.1",
|
||||
"port": 1080,
|
||||
"dc_ip": ["broken"],
|
||||
"verbose": False,
|
||||
})
|
||||
|
||||
self.assertFalse(started)
|
||||
self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"])
|
||||
|
||||
def test_run_proxy_thread_reports_generic_runtime_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
errors = []
|
||||
|
||||
async def fake_run_proxy(stop_event=None):
|
||||
raise RuntimeError("proxy boom")
|
||||
|
||||
runtime = ProxyAppRuntime(
|
||||
Path(tmpdir),
|
||||
on_error=errors.append,
|
||||
run_proxy=fake_run_proxy,
|
||||
)
|
||||
|
||||
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
|
||||
|
||||
self.assertEqual(errors, ["proxy boom"])
|
||||
|
||||
def test_run_proxy_thread_reports_port_in_use_case_insensitively(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
errors = []
|
||||
|
||||
async def fake_run_proxy(stop_event=None):
|
||||
raise RuntimeError(
|
||||
"[Errno 98] error while attempting to bind on address "
|
||||
"('127.0.0.1', 1443): address already in use"
|
||||
)
|
||||
|
||||
runtime = ProxyAppRuntime(
|
||||
Path(tmpdir),
|
||||
on_error=errors.append,
|
||||
run_proxy=fake_run_proxy,
|
||||
)
|
||||
|
||||
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
|
||||
|
||||
self.assertEqual(
|
||||
errors,
|
||||
[
|
||||
"Не удалось запустить прокси:\n"
|
||||
"Порт уже используется другим приложением.\n\n"
|
||||
"Закройте приложение, использующее этот порт, "
|
||||
"или измените порт в настройках прокси и перезапустите."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
import hashlib
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from proxy.crypto_backend import create_aes_ctr_transform
|
||||
from proxy.tg_ws_proxy import (
|
||||
PROTO_ABRIDGED_INT,
|
||||
PROTO_TAG_ABRIDGED,
|
||||
_MsgSplitter,
|
||||
_generate_relay_init,
|
||||
_try_handshake,
|
||||
)
|
||||
|
||||
|
||||
KEY = bytes(range(32))
|
||||
IV = bytes(range(16))
|
||||
SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
|
||||
|
||||
|
||||
def _xor(left: bytes, right: bytes) -> bytes:
|
||||
return bytes(a ^ b for a, b in zip(left, right))
|
||||
|
||||
|
||||
def _keystream(size: int, key: bytes, iv: bytes) -> bytes:
|
||||
transform = Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor()
|
||||
return transform.update(b"\x00" * size)
|
||||
|
||||
|
||||
def _build_client_handshake(
|
||||
dc_raw: int,
|
||||
proto_tag: bytes = PROTO_TAG_ABRIDGED,
|
||||
secret: bytes = SECRET,
|
||||
) -> bytes:
|
||||
packet = bytearray(64)
|
||||
packet[8:40] = KEY
|
||||
packet[40:56] = IV
|
||||
|
||||
dec_key = hashlib.sha256(KEY + secret).digest()
|
||||
plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
|
||||
packet[56:64] = _xor(plain_tail, _keystream(64, dec_key, IV)[56:64])
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
def _encrypt_after_init(relay_init: bytes, plaintext: bytes) -> bytes:
|
||||
transform = Cipher(
|
||||
algorithms.AES(relay_init[8:40]),
|
||||
modes.CTR(relay_init[40:56]),
|
||||
).encryptor()
|
||||
transform.update(b"\x00" * 64)
|
||||
return transform.update(plaintext)
|
||||
|
||||
|
||||
class CryptoBackendTests(unittest.TestCase):
|
||||
def test_python_backend_matches_cryptography_stream(self):
|
||||
cryptography_transform = create_aes_ctr_transform(
|
||||
KEY, IV, backend="cryptography")
|
||||
python_transform = create_aes_ctr_transform(KEY, IV, backend="python")
|
||||
|
||||
chunks = [
|
||||
b"",
|
||||
b"\x00" * 16,
|
||||
bytes(range(31)),
|
||||
b"telegram-proxy",
|
||||
b"\xff" * 64,
|
||||
]
|
||||
|
||||
cryptography_out = b"".join(
|
||||
cryptography_transform.update(chunk) for chunk in chunks
|
||||
) + cryptography_transform.finalize()
|
||||
python_out = b"".join(
|
||||
python_transform.update(chunk) for chunk in chunks
|
||||
) + python_transform.finalize()
|
||||
|
||||
self.assertEqual(python_out, cryptography_out)
|
||||
|
||||
def test_unknown_backend_raises_error(self):
|
||||
with self.assertRaises(ValueError):
|
||||
create_aes_ctr_transform(KEY, IV, backend="missing")
|
||||
|
||||
|
||||
class MtProtoHandshakeTests(unittest.TestCase):
|
||||
def test_try_handshake_reads_non_media_dc(self):
|
||||
handshake = _build_client_handshake(dc_raw=2)
|
||||
|
||||
result = _try_handshake(handshake, SECRET)
|
||||
|
||||
self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
|
||||
|
||||
def test_try_handshake_reads_media_dc(self):
|
||||
handshake = _build_client_handshake(dc_raw=-4)
|
||||
|
||||
result = _try_handshake(handshake, SECRET)
|
||||
|
||||
self.assertEqual(result[:3], (4, True, PROTO_TAG_ABRIDGED))
|
||||
|
||||
def test_try_handshake_rejects_wrong_secret(self):
|
||||
handshake = _build_client_handshake(dc_raw=2)
|
||||
|
||||
result = _try_handshake(
|
||||
handshake,
|
||||
bytes.fromhex("fedcba9876543210fedcba9876543210"),
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_generate_relay_init_encodes_proto_and_signed_dc(self):
|
||||
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -3)
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(relay_init[8:40]),
|
||||
modes.CTR(relay_init[40:56]),
|
||||
).encryptor()
|
||||
|
||||
decrypted = decryptor.update(relay_init)
|
||||
|
||||
self.assertEqual(decrypted[56:60], PROTO_TAG_ABRIDGED)
|
||||
self.assertEqual(struct.unpack("<h", decrypted[60:62])[0], -3)
|
||||
|
||||
|
||||
class MsgSplitterTests(unittest.TestCase):
|
||||
def test_splitter_splits_multiple_abridged_messages(self):
|
||||
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
|
||||
plain_chunk = b"\x01abcd\x02EFGH1234"
|
||||
encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
|
||||
|
||||
parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
|
||||
|
||||
self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]])
|
||||
|
||||
def test_splitter_leaves_single_message_intact(self):
|
||||
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, 2)
|
||||
plain_chunk = b"\x02abcdefgh"
|
||||
encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
|
||||
|
||||
parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
|
||||
|
||||
self.assertEqual(parts, [encrypted_chunk])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,62 @@
|
||||
import hashlib
|
||||
import struct
|
||||
import unittest
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from proxy.tg_ws_proxy import (
|
||||
PROTO_TAG_ABRIDGED,
|
||||
PROTO_TAG_INTERMEDIATE,
|
||||
_generate_relay_init,
|
||||
_try_handshake,
|
||||
)
|
||||
|
||||
|
||||
KEY = bytes(range(32))
|
||||
IV = bytes(range(16))
|
||||
SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
|
||||
|
||||
|
||||
def _xor(left: bytes, right: bytes) -> bytes:
|
||||
return bytes(a ^ b for a, b in zip(left, right))
|
||||
|
||||
|
||||
def _build_client_handshake(dc_raw: int, proto_tag: bytes) -> bytes:
|
||||
packet = bytearray(64)
|
||||
packet[8:40] = KEY
|
||||
packet[40:56] = IV
|
||||
|
||||
dec_key = hashlib.sha256(KEY + SECRET).digest()
|
||||
decryptor = Cipher(algorithms.AES(dec_key), modes.CTR(IV)).encryptor()
|
||||
keystream = decryptor.update(b"\x00" * 64)
|
||||
|
||||
plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
|
||||
packet[56:64] = _xor(plain_tail, keystream[56:64])
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
class MtProtoProtocolTests(unittest.TestCase):
|
||||
def test_try_handshake_accepts_abridged_proto(self):
|
||||
handshake = _build_client_handshake(2, PROTO_TAG_ABRIDGED)
|
||||
|
||||
result = _try_handshake(handshake, SECRET)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
|
||||
|
||||
def test_try_handshake_accepts_intermediate_proto(self):
|
||||
handshake = _build_client_handshake(-4, PROTO_TAG_INTERMEDIATE)
|
||||
|
||||
result = _try_handshake(handshake, SECRET)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result[:3], (4, True, PROTO_TAG_INTERMEDIATE))
|
||||
|
||||
def test_generate_relay_init_produces_handshake_sized_packet(self):
|
||||
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
|
||||
|
||||
self.assertEqual(len(relay_init), 64)
|
||||
self.assertEqual(relay_init[0], relay_init[0] & 0xFF)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
import unittest
|
||||
|
||||
from proxy import __version__
|
||||
from utils import update_check
|
||||
|
||||
|
||||
class UpdateCheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig_state = dict(update_check._state)
|
||||
|
||||
def tearDown(self):
|
||||
update_check._state.clear()
|
||||
update_check._state.update(self._orig_state)
|
||||
|
||||
def test_apply_release_tag_marks_update_available(self):
|
||||
version_parts = [int(part) for part in __version__.split(".")]
|
||||
version_parts[-1] += 1
|
||||
next_version = ".".join(str(part) for part in version_parts)
|
||||
update_check._apply_release_tag(
|
||||
tag=f"v{next_version}",
|
||||
html_url="https://example.com/release",
|
||||
current_version=__version__,
|
||||
)
|
||||
|
||||
status = update_check.get_status()
|
||||
self.assertTrue(status["has_update"])
|
||||
self.assertFalse(status["ahead_of_release"])
|
||||
self.assertEqual(status["latest"], next_version)
|
||||
self.assertEqual(status["html_url"], "https://example.com/release")
|
||||
|
||||
def test_apply_release_tag_marks_ahead_of_release(self):
|
||||
update_check._apply_release_tag(
|
||||
tag="v1.2.1",
|
||||
html_url="https://example.com/release",
|
||||
current_version=__version__,
|
||||
)
|
||||
|
||||
status = update_check.get_status()
|
||||
self.assertFalse(status["has_update"])
|
||||
self.assertTrue(status["ahead_of_release"])
|
||||
self.assertEqual(status["latest"], "1.2.1")
|
||||
|
||||
def test_apply_release_tag_marks_latest_when_versions_match(self):
|
||||
update_check._apply_release_tag(
|
||||
tag=f"v{__version__}",
|
||||
html_url="https://example.com/release",
|
||||
current_version=__version__,
|
||||
)
|
||||
|
||||
status = update_check.get_status()
|
||||
self.assertFalse(status["has_update"])
|
||||
self.assertFalse(status["ahead_of_release"])
|
||||
self.assertEqual(status["latest"], __version__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-5
@@ -51,11 +51,8 @@ def ctk_theme_for_platform() -> CtkTheme:
|
||||
return CtkTheme()
|
||||
|
||||
|
||||
_APPEARANCE_MODE_MAP = {"auto": "system", "light": "Light", "dark": "Dark"}
|
||||
|
||||
|
||||
def apply_ctk_appearance(ctk: Any, mode: str = "auto") -> None:
|
||||
ctk.set_appearance_mode(_APPEARANCE_MODE_MAP.get(mode, "system"))
|
||||
def apply_ctk_appearance(ctk: Any) -> None:
|
||||
ctk.set_appearance_mode("auto")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
|
||||
|
||||
+100
-583
@@ -1,251 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list
|
||||
from proxy.balancer import balancer
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
from proxy import __version__
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||
|
||||
|
||||
from ui.ctk_theme import (
|
||||
FIRST_RUN_FRAME_PAD,
|
||||
CtkTheme,
|
||||
main_content_frame,
|
||||
)
|
||||
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,
|
||||
|
||||
_TIP_HOST = (
|
||||
"Адрес, на котором прокси принимает подключения.\n"
|
||||
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
|
||||
)
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
_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"
|
||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
||||
_CFWORKER_TEST_DST = {
|
||||
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 _run_connectivity_test(cases: list) -> dict:
|
||||
import base64
|
||||
import ssl
|
||||
import socket as _socket
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
results = {}
|
||||
for dc, connect_host, sni_host, req_host, path in cases:
|
||||
try:
|
||||
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {req_host}\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"Sec-WebSocket-Protocol: binary\r\n"
|
||||
f"\r\n"
|
||||
).encode()
|
||||
ssock.sendall(req)
|
||||
ssock.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = ssock.recv(512)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
||||
if "101" in first:
|
||||
results[dc] = True
|
||||
else:
|
||||
results[dc] = first or t("connectivity.no_response")
|
||||
ssock.close()
|
||||
raw.close()
|
||||
except _socket.timeout:
|
||||
results[dc] = t("connectivity.timeout")
|
||||
except OSError as exc:
|
||||
msg = str(exc)
|
||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
||||
return results
|
||||
|
||||
|
||||
def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
host = f"kws{dc}.{domain}"
|
||||
cases.append((dc, host, host, host, "/apiws"))
|
||||
return _run_connectivity_test(cases)
|
||||
|
||||
|
||||
def _run_cfworker_connectivity_test(domain: str) -> dict:
|
||||
cases = []
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
dst = _CFWORKER_TEST_DST[dc]
|
||||
path = f"/apiws?dst={dst}&dc={dc}&media=0"
|
||||
cases.append((dc, domain, domain, domain, path))
|
||||
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:
|
||||
merged: dict = {}
|
||||
best_domain = None
|
||||
for domain in reversed(domains):
|
||||
res = _run_cfproxy_connectivity_test(domain)
|
||||
if all(v is True for v in res.values()):
|
||||
return domain, res
|
||||
for dc, v in res.items():
|
||||
if v is True:
|
||||
merged[dc] = True
|
||||
best_domain = domain
|
||||
elif dc not in merged:
|
||||
merged[dc] = v
|
||||
return best_domain, merged
|
||||
|
||||
|
||||
def _show_connectivity_results(title_base: str, results: dict,
|
||||
domain: str = '', label_prefix: str = 'DC',
|
||||
auto_mode: bool = False,
|
||||
unavailable_message: str = '') -> None:
|
||||
import tkinter as _tk
|
||||
from tkinter import messagebox as _mb
|
||||
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
total = len(_CFPROXY_TEST_DCS)
|
||||
if auto_mode:
|
||||
if domain:
|
||||
title = t("connectivity.available", title=title_base)
|
||||
msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total)
|
||||
else:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
msg = unavailable_message
|
||||
else:
|
||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||
if len(ok) == total:
|
||||
title = t("connectivity.all_ok", title=title_base)
|
||||
msg = t("connectivity.all_ok_domain", total=total, domain=domain)
|
||||
elif not ok:
|
||||
title = t("connectivity.unavailable", title=title_base)
|
||||
errors = "\n".join(
|
||||
t("connectivity.error_line", prefix=label_prefix, dc=dc, error=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.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
_mb.showinfo(title, msg, parent=root)
|
||||
root.destroy()
|
||||
_TIP_PORT = (
|
||||
"Порт прокси. В Telegram Desktop в настройках прокси должен быть "
|
||||
"указан тот же порт"
|
||||
)
|
||||
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
|
||||
_TIP_DC = (
|
||||
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
|
||||
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
|
||||
"Прокси по этим правилам направляет трафик к нужным серверам Telegram"
|
||||
)
|
||||
_TIP_VERBOSE = (
|
||||
"Если включено, в файл логов пишется больше подробностей — "
|
||||
"необходимо при поиске неполадок"
|
||||
)
|
||||
_TIP_BUF_KB = (
|
||||
"Размер буфера приёма/передачи в килобайтах.\n"
|
||||
"Больше значение — больше выделение памяти на сокет"
|
||||
)
|
||||
_TIP_POOL = (
|
||||
"Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n"
|
||||
"Увеличение может помочь при высокой нагрузке"
|
||||
)
|
||||
_TIP_LOG_MB = (
|
||||
"Максимальный размер файла лога; при достижении лимита файл перезаписывается"
|
||||
)
|
||||
_TIP_AUTOSTART = (
|
||||
"Запускать TG WS Proxy при входе в Windows. "
|
||||
"Если вы переместите программу в другую папку, автозапуск сбросится"
|
||||
)
|
||||
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
|
||||
_TIP_SAVE = "Сохранить настройки"
|
||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
||||
|
||||
_INNER_W = 396
|
||||
|
||||
_APPEARANCE_KEYS = ("auto", "light", "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):
|
||||
opts = dict(
|
||||
@@ -311,10 +120,6 @@ def tray_settings_scroll_and_footer(
|
||||
scrollbar_button_hover_color=theme.text_secondary,
|
||||
)
|
||||
scroll.pack(fill="both", expand=True)
|
||||
try:
|
||||
scroll._parent_canvas.configure(yscrollincrement=4)
|
||||
except Exception:
|
||||
pass
|
||||
return scroll, footer
|
||||
|
||||
|
||||
@@ -350,11 +155,6 @@ class TrayConfigFormWidgets:
|
||||
adv_keys: Tuple[str, ...]
|
||||
autostart_var: Optional[Any]
|
||||
check_updates_var: Optional[Any]
|
||||
cfproxy_var: Optional[Any] = None
|
||||
cfproxy_user_domain_var: Optional[Any] = None
|
||||
cfproxy_worker_domain_var: Optional[Any] = None
|
||||
appearance_var: Optional[Any] = None
|
||||
language_var: Optional[Any] = None
|
||||
|
||||
|
||||
def install_tray_config_form(
|
||||
@@ -366,15 +166,11 @@ def install_tray_config_form(
|
||||
*,
|
||||
show_autostart: bool = False,
|
||||
autostart_value: bool = False,
|
||||
on_language_change: Optional[Callable[[], None]] = None,
|
||||
) -> TrayConfigFormWidgets:
|
||||
lang_cfg = cfg.get("language", default_config["language"])
|
||||
set_language(lang_cfg)
|
||||
|
||||
header = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
header.pack(fill="x", pady=(0, 2))
|
||||
ctk.CTkLabel(
|
||||
header, text=t("settings.title"),
|
||||
header, text="Настройки прокси",
|
||||
font=(theme.ui_font_family, 17, "bold"),
|
||||
text_color=theme.text_primary, anchor="w",
|
||||
).pack(side="left")
|
||||
@@ -382,101 +178,24 @@ def install_tray_config_form(
|
||||
header, text=f"v{__version__}",
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_secondary, anchor="e",
|
||||
).pack(side="right", padx=(4, 0))
|
||||
).pack(side="right")
|
||||
|
||||
appearance_var = ctk.StringVar(
|
||||
value=_appearance_from_cfg(cfg.get("appearance", "auto"))
|
||||
)
|
||||
|
||||
def _on_appearance_change(choice: str) -> None:
|
||||
cfg_val = _appearance_to_cfg(choice)
|
||||
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
|
||||
cfg["appearance"] = cfg_val
|
||||
|
||||
ctk.CTkButton(
|
||||
header, text="Donate ♥", width=90, height=28,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color="#22c55e", hover_color="#16a34a",
|
||||
text_color="#ffffff", border_width=0,
|
||||
command=lambda: (
|
||||
header.winfo_toplevel().iconify(),
|
||||
webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md"),
|
||||
),
|
||||
).pack(side="right", padx=(0, 6))
|
||||
|
||||
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"))
|
||||
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
|
||||
|
||||
host_row = ctk.CTkFrame(conn, fg_color="transparent")
|
||||
host_row.pack(fill="x")
|
||||
|
||||
host_col, host_var = _labeled_entry(
|
||||
ctk, host_row, theme, t("label.host"),
|
||||
ctk, host_row, theme, "IP-адрес",
|
||||
cfg.get("host", default_config["host"]),
|
||||
tip=t("tip.host"), width=160, pack_fill=True,
|
||||
tip=_TIP_HOST, width=160, pack_fill=True,
|
||||
)
|
||||
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
port_col, port_var = _labeled_entry(
|
||||
ctk, host_row, theme, t("label.port"),
|
||||
ctk, host_row, theme, "Порт",
|
||||
cfg.get("port", default_config["port"]),
|
||||
tip=t("tip.port"), width=100,
|
||||
tip=_TIP_PORT, width=100,
|
||||
)
|
||||
port_col.pack(side="left")
|
||||
|
||||
@@ -484,9 +203,9 @@ def install_tray_config_form(
|
||||
secret_row.pack(fill="x")
|
||||
|
||||
secret_col, secret_var = _labeled_entry(
|
||||
ctk, secret_row, theme, t("label.secret"),
|
||||
ctk, secret_row, theme, "Secret",
|
||||
cfg.get("secret", default_config["secret"]),
|
||||
tip=t("tip.secret"), width=160, pack_fill=True,
|
||||
tip=_TIP_SECRET, width=160, pack_fill=True,
|
||||
)
|
||||
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
@@ -501,8 +220,8 @@ def install_tray_config_form(
|
||||
command=lambda: secret_var.set(os.urandom(16).hex()),
|
||||
).pack()
|
||||
|
||||
dc_inner = _config_section(ctk, frame, theme, t("section.dc"))
|
||||
dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11)
|
||||
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
|
||||
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
|
||||
dc_lbl.pack(anchor="w", pady=(0, 4))
|
||||
dc_textbox = ctk.CTkTextbox(
|
||||
dc_inner, width=_INNER_W, height=88,
|
||||
@@ -512,205 +231,22 @@ def install_tray_config_form(
|
||||
)
|
||||
dc_textbox.pack(fill="x")
|
||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc"))
|
||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
|
||||
|
||||
cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy"))
|
||||
|
||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_row.pack(fill="x", pady=(0, 4))
|
||||
|
||||
cfproxy_var = ctk.BooleanVar(
|
||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||
)
|
||||
cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var)
|
||||
cf_cb.pack(side="left", padx=(0, 16))
|
||||
attach_ctk_tooltip(cf_cb, t("tip.cfproxy"))
|
||||
|
||||
_cf_test_btn = [None]
|
||||
|
||||
def _on_cf_test():
|
||||
user_domains = (
|
||||
coerce_domain_list(cfproxy_user_domain_var.get())
|
||||
if cf_custom_cb_var.get() else []
|
||||
)
|
||||
btn = _cf_test_btn[0]
|
||||
if btn:
|
||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||
import threading as _threading
|
||||
if user_domains:
|
||||
def _worker():
|
||||
try:
|
||||
per = _run_cfproxy_multi_test(user_domains)
|
||||
if btn:
|
||||
btn.after(
|
||||
0,
|
||||
lambda: _show_multi_connectivity_results(
|
||||
t("connectivity.cfproxy_title"), per, label_prefix='kws',
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("CF proxy test failed: %s", exc)
|
||||
finally:
|
||||
if btn:
|
||||
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
||||
_threading.Thread(target=_worker, daemon=True).start()
|
||||
else:
|
||||
def _worker_auto():
|
||||
try:
|
||||
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
|
||||
if btn:
|
||||
btn.after(
|
||||
0,
|
||||
lambda: _show_connectivity_results(
|
||||
t("connectivity.cfproxy_title"), res,
|
||||
domain=ok_domain or '',
|
||||
auto_mode=True,
|
||||
unavailable_message=t("connectivity.cf_auto_fail"),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("CF proxy auto-test failed: %s", exc)
|
||||
finally:
|
||||
if btn:
|
||||
btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal"))
|
||||
_threading.Thread(target=_worker_auto, daemon=True).start()
|
||||
|
||||
_cf_test_widget = ctk.CTkButton(
|
||||
cf_row, text=t("button.test"), width=56, height=28,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=_on_cf_test,
|
||||
)
|
||||
_cf_test_widget.pack(side="right")
|
||||
_cf_test_btn[0] = _cf_test_widget
|
||||
|
||||
cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_custom_row.pack(fill="x")
|
||||
|
||||
saved_user_domains = coerce_domain_list(
|
||||
cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
|
||||
)
|
||||
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains))
|
||||
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var)
|
||||
cf_custom_cb.pack(side="left", padx=(0, 10))
|
||||
attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb"))
|
||||
|
||||
ctk.CTkButton(
|
||||
cf_custom_row, text="?", width=28, height=32,
|
||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||
).pack(side="right")
|
||||
|
||||
cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains))
|
||||
cf_domain_entry = _entry(
|
||||
ctk, cf_custom_row, theme, var=cfproxy_user_domain_var,
|
||||
height=32, radius=8,
|
||||
)
|
||||
cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain"))
|
||||
|
||||
def _sync_domain_entry(*_):
|
||||
state = "normal" if cf_custom_cb_var.get() else "disabled"
|
||||
cf_domain_entry.configure(state=state)
|
||||
if not cf_custom_cb_var.get():
|
||||
cfproxy_user_domain_var.set("")
|
||||
|
||||
cf_custom_cb_var.trace_add("write", _sync_domain_entry)
|
||||
_sync_domain_entry()
|
||||
|
||||
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.pack(fill="x", pady=(0, 4))
|
||||
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_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent")
|
||||
cf_worker_input.pack(fill="x")
|
||||
|
||||
cfproxy_worker_domain_var = ctk.StringVar(
|
||||
value=", ".join(coerce_domain_list(
|
||||
cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", ""))
|
||||
))
|
||||
)
|
||||
cf_worker_entry = _entry(
|
||||
ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var,
|
||||
height=32, radius=8,
|
||||
)
|
||||
cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||||
attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain"))
|
||||
|
||||
_cfworker_test_btn = [None]
|
||||
|
||||
def _sync_cfworker_test_button(*_):
|
||||
btn = _cfworker_test_btn[0]
|
||||
if btn is None:
|
||||
return
|
||||
enabled = bool(coerce_domain_list(cfproxy_worker_domain_var.get()))
|
||||
btn.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def _on_cfworker_test():
|
||||
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
|
||||
btn = _cfworker_test_btn[0]
|
||||
if not domains or btn is None:
|
||||
return
|
||||
btn.configure(text=t("button.test_loading"), state="disabled")
|
||||
import threading as _threading
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
per = _run_cfworker_multi_test(domains)
|
||||
btn.after(
|
||||
0,
|
||||
lambda: _show_multi_connectivity_results(
|
||||
t("connectivity.cfworker_title"), per, label_prefix='DC',
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("CF worker test failed: %s", exc)
|
||||
finally:
|
||||
btn.after(0, lambda: btn.configure(text=t("button.test")))
|
||||
btn.after(0, _sync_cfworker_test_button)
|
||||
|
||||
_threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
ctk.CTkButton(
|
||||
cf_worker_input, text="?", width=28, height=32,
|
||||
font=(theme.ui_font_family, 14), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: webbrowser.open(_CFWORKER_HELP_URL),
|
||||
).pack(side="right")
|
||||
|
||||
_cfworker_test_widget = ctk.CTkButton(
|
||||
cf_worker_input, text=t("button.test"), width=56, height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=_on_cfworker_test,
|
||||
)
|
||||
_cfworker_test_widget.pack(side="right", padx=(0, 6))
|
||||
_cfworker_test_btn[0] = _cfworker_test_widget
|
||||
cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button)
|
||||
_sync_cfworker_test_button()
|
||||
|
||||
log_inner = _config_section(ctk, frame, theme, t("section.logs"))
|
||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
||||
|
||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
||||
verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var)
|
||||
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
|
||||
verbose_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
|
||||
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
|
||||
|
||||
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
|
||||
adv_frame.pack(fill="x")
|
||||
|
||||
adv_rows = [
|
||||
(t("label.buf_kb"), "buf_kb", t("tip.buf_kb")),
|
||||
(t("label.pool_size"), "pool_size", t("tip.pool")),
|
||||
(t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")),
|
||||
("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB),
|
||||
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
|
||||
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
|
||||
]
|
||||
for label_text, key, tip in adv_rows:
|
||||
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
|
||||
@@ -727,32 +263,38 @@ def install_tray_config_form(
|
||||
adv_entries = list(adv_frame.winfo_children())
|
||||
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
|
||||
|
||||
upd_inner = _config_section(ctk, frame, theme, t("section.updates"))
|
||||
upd_inner = _config_section(ctk, frame, theme, "Обновления")
|
||||
st = get_status()
|
||||
check_updates_var = ctk.BooleanVar(
|
||||
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||
)
|
||||
upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var)
|
||||
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
|
||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
|
||||
|
||||
if st.get("error"):
|
||||
upd_status = t("updates.status_error")
|
||||
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
|
||||
elif not st.get("checked"):
|
||||
upd_status = t("updates.status_pending")
|
||||
upd_status = "Статус появится после фоновой проверки при запуске."
|
||||
elif st.get("has_update") and st.get("latest"):
|
||||
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||
upd_status = (
|
||||
f"На GitHub доступна версия {st['latest']} "
|
||||
f"(у вас {__version__})."
|
||||
)
|
||||
elif st.get("ahead_of_release") and st.get("latest"):
|
||||
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||
upd_status = (
|
||||
f"У вас {__version__} — новее последнего релиза на GitHub "
|
||||
f"({st['latest']})."
|
||||
)
|
||||
else:
|
||||
upd_status = t("updates.status_latest")
|
||||
upd_status = "Установлена последняя известная версия с GitHub."
|
||||
|
||||
_label(ctk, upd_inner, theme, upd_status, size=11,
|
||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
||||
|
||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ctk.CTkButton(
|
||||
upd_inner, text=t("button.open_release"), height=32,
|
||||
upd_inner, text="Открыть страницу релиза", height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
@@ -762,28 +304,23 @@ def install_tray_config_form(
|
||||
|
||||
autostart_var = None
|
||||
if show_autostart:
|
||||
sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4)
|
||||
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
|
||||
autostart_var = ctk.BooleanVar(value=autostart_value)
|
||||
as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var)
|
||||
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
|
||||
as_cb.pack(anchor="w", pady=(0, 4))
|
||||
as_hint = _label(
|
||||
ctk, sys_inner, theme,
|
||||
t("label.autostart_hint"),
|
||||
"Если переместить программу в другую папку, запись автозапуска может сброситься.",
|
||||
size=11, justify="left", wraplength=_INNER_W,
|
||||
)
|
||||
as_hint.pack(anchor="w")
|
||||
attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart"))
|
||||
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
|
||||
|
||||
return TrayConfigFormWidgets(
|
||||
host_var=host_var, port_var=port_var, secret_var=secret_var,
|
||||
dc_textbox=dc_textbox, verbose_var=verbose_var,
|
||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||
cfproxy_var=cfproxy_var,
|
||||
cfproxy_user_domain_var=cfproxy_user_domain_var,
|
||||
cfproxy_worker_domain_var=cfproxy_worker_domain_var,
|
||||
appearance_var=appearance_var,
|
||||
language_var=language_var,
|
||||
)
|
||||
|
||||
|
||||
@@ -804,16 +341,6 @@ def merge_adv_from_form(
|
||||
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(
|
||||
widgets: TrayConfigFormWidgets,
|
||||
default_config: dict,
|
||||
@@ -826,32 +353,32 @@ def validate_config_form(
|
||||
try:
|
||||
_sock.inet_aton(host_val)
|
||||
except OSError:
|
||||
return t("validation.bad_host")
|
||||
return "Некорректный IP-адрес."
|
||||
|
||||
try:
|
||||
port_val = int(widgets.port_var.get().strip())
|
||||
if not (1 <= port_val <= 65535):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
return t("validation.bad_port")
|
||||
return "Порт должен быть числом 1-65535"
|
||||
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
||||
if line.strip()
|
||||
l.strip()
|
||||
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
||||
if l.strip()
|
||||
]
|
||||
try:
|
||||
parse_dc_ip_list(lines)
|
||||
tg_ws_proxy.parse_dc_ip_list(lines)
|
||||
except ValueError as e:
|
||||
return _dc_validation_message(e)
|
||||
return str(e)
|
||||
|
||||
secret_val = widgets.secret_var.get().strip()
|
||||
if len(secret_val) != 32:
|
||||
return t("validation.bad_secret_len")
|
||||
return "Secret должен содержать ровно 32 hex-символа (16 байт)."
|
||||
try:
|
||||
bytes.fromhex(secret_val)
|
||||
except ValueError:
|
||||
return t("validation.bad_secret_hex")
|
||||
return "Secret должен состоять только из hex-символов (0-9, a-f)."
|
||||
|
||||
new_cfg: Dict[str, Any] = {
|
||||
"host": host_val,
|
||||
@@ -870,16 +397,6 @@ def validate_config_form(
|
||||
merge_adv_from_form(widgets, new_cfg, default_config)
|
||||
if widgets.check_updates_var is not None:
|
||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||
if widgets.cfproxy_var is not None:
|
||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||
if widgets.cfproxy_user_domain_var is not None:
|
||||
new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get())
|
||||
if widgets.cfproxy_worker_domain_var is not None:
|
||||
new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get())
|
||||
if widgets.appearance_var is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -900,22 +417,22 @@ def install_tray_config_buttons(
|
||||
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
btn_frame.pack(fill="x", pady=(0, 0))
|
||||
save_btn = ctk.CTkButton(
|
||||
btn_frame, text=t("button.save"), height=38,
|
||||
btn_frame, text="Сохранить", height=38,
|
||||
font=(theme.ui_font_family, 14, "bold"), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
command=on_save)
|
||||
save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||
attach_ctk_tooltip(save_btn, t("tip.save"))
|
||||
attach_ctk_tooltip(save_btn, _TIP_SAVE)
|
||||
cancel_btn = ctk.CTkButton(
|
||||
btn_frame, text=t("button.cancel"), height=38,
|
||||
btn_frame, text="Отмена", height=38,
|
||||
font=(theme.ui_font_family, 14), corner_radius=10,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=on_cancel)
|
||||
cancel_btn.pack(side="right", fill="x", expand=True)
|
||||
attach_ctk_tooltip(cancel_btn, t("tip.cancel"))
|
||||
attach_ctk_tooltip(cancel_btn, _TIP_CANCEL)
|
||||
|
||||
|
||||
def populate_first_run_window(
|
||||
@@ -928,7 +445,7 @@ def populate_first_run_window(
|
||||
secret: str,
|
||||
on_done: Callable[[bool], None],
|
||||
) -> None:
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
|
||||
fpx, fpy = FIRST_RUN_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
@@ -940,19 +457,19 @@ def populate_first_run_window(
|
||||
width=4, height=32, corner_radius=2)
|
||||
accent_bar.pack(side="left", padx=(0, 12))
|
||||
|
||||
ctk.CTkLabel(title_frame, text=t("first_run.title"),
|
||||
ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее",
|
||||
font=(theme.ui_font_family, 17, "bold"),
|
||||
text_color=theme.text_primary).pack(side="left")
|
||||
|
||||
sections = [
|
||||
(t("first_run.how_to"), True),
|
||||
(t("first_run.auto"), True),
|
||||
(t("first_run.auto_hint"), False),
|
||||
(t("first_run.auto_link", url=tg_url), False),
|
||||
("\n" + t("first_run.manual"), True),
|
||||
(t("first_run.manual_path"), False),
|
||||
(t("first_run.manual_mtproto", host=link_host, port=port), False),
|
||||
(t("first_run.manual_secret", secret=secret), False),
|
||||
("Как подключить Telegram Desktop:", True),
|
||||
(" Автоматически:", True),
|
||||
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
|
||||
(f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False),
|
||||
("\n Вручную:", True),
|
||||
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
|
||||
(f" MTProto → {link_host} : {port}", False),
|
||||
(f" Secret: dd{secret}", False),
|
||||
]
|
||||
|
||||
textbox = ctk.CTkTextbox(
|
||||
@@ -984,13 +501,13 @@ def populate_first_run_window(
|
||||
corner_radius=0).pack(fill="x", pady=(0, 12))
|
||||
|
||||
auto_var = ctk.BooleanVar(value=True)
|
||||
_checkbox(ctk, frame, theme, t("first_run.open_now"),
|
||||
_checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас",
|
||||
auto_var).pack(anchor="w", pady=(0, 16))
|
||||
|
||||
def on_ok():
|
||||
on_done(auto_var.get())
|
||||
|
||||
ctk.CTkButton(frame, text=t("button.start"), width=180, height=42,
|
||||
ctk.CTkButton(frame, text="Начать", width=180, height=42,
|
||||
font=(theme.ui_font_family, 15, "bold"), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
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
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"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,8 +8,6 @@ import sys
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from ui.i18n import detect_system_language
|
||||
|
||||
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||
"port": 1443,
|
||||
"host": "127.0.0.1",
|
||||
@@ -19,17 +17,12 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||
"log_max_mb": 5,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"cfproxy": True,
|
||||
"cfproxy_user_domain": [],
|
||||
"cfproxy_worker_domain": [],
|
||||
"ws_keepalive_interval": 30,
|
||||
}
|
||||
|
||||
|
||||
def default_tray_config() -> Dict[str, Any]:
|
||||
cfg = dict(_TRAY_DEFAULTS_COMMON)
|
||||
cfg["secret"] = os.urandom(16).hex()
|
||||
cfg["language"] = detect_system_language().value
|
||||
|
||||
if sys.platform == "win32":
|
||||
cfg["autostart"] = False
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
"""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",
|
||||
)
|
||||
+61
-127
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import shutil
|
||||
import socket as _socket
|
||||
import sys
|
||||
import threading
|
||||
@@ -14,19 +14,16 @@ from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
import psutil
|
||||
|
||||
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from proxy.tg_ws_proxy import _run
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
from proxy import __version__
|
||||
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")
|
||||
|
||||
APP_NAME = "TgWsProxy"
|
||||
PORTABLE_DIR_NAME = "TgWsProxy_data"
|
||||
|
||||
|
||||
def _standard_app_dir() -> Path:
|
||||
def _app_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
|
||||
if sys.platform == "darwin":
|
||||
@@ -34,61 +31,6 @@ def _standard_app_dir() -> Path:
|
||||
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()
|
||||
CONFIG_FILE = APP_DIR / "config.json"
|
||||
LOG_FILE = APP_DIR / "proxy.log"
|
||||
@@ -109,7 +51,7 @@ def ensure_dirs() -> None:
|
||||
_lock_file_path: Optional[Path] = None
|
||||
|
||||
|
||||
def _same_process(meta: dict, proc: psutil.Process) -> bool:
|
||||
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
|
||||
try:
|
||||
lock_ct = float(meta.get("create_time", 0.0))
|
||||
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
|
||||
@@ -118,20 +60,23 @@ def _same_process(meta: dict, proc: psutil.Process) -> bool:
|
||||
return False
|
||||
if IS_FROZEN:
|
||||
return APP_NAME.lower() in proc.name().lower()
|
||||
try:
|
||||
for arg in proc.cmdline():
|
||||
if script_hint in arg:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def acquire_lock() -> bool:
|
||||
def acquire_lock(script_hint: str = "") -> bool:
|
||||
global _lock_file_path
|
||||
ensure_dirs()
|
||||
for f in list(APP_DIR.glob("*.lock")):
|
||||
try:
|
||||
pid = int(f.stem)
|
||||
except Exception:
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
f.unlink(missing_ok=True)
|
||||
continue
|
||||
meta: dict = {}
|
||||
try:
|
||||
@@ -140,17 +85,12 @@ def acquire_lock() -> bool:
|
||||
meta = json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
is_running = False
|
||||
try:
|
||||
is_running = _same_process(meta, psutil.Process(pid))
|
||||
if _same_process(meta, psutil.Process(pid), script_hint):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
if is_running:
|
||||
return False
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
lock_file = APP_DIR / f"{os.getpid()}.lock"
|
||||
try:
|
||||
@@ -160,10 +100,7 @@ def acquire_lock() -> bool:
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
lock_file.touch()
|
||||
except Exception:
|
||||
pass
|
||||
lock_file.touch()
|
||||
_lock_file_path = lock_file
|
||||
return True
|
||||
|
||||
@@ -180,28 +117,18 @@ def release_lock() -> None:
|
||||
|
||||
# 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:
|
||||
ensure_dirs()
|
||||
cfg: Optional[dict] = None
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for k, v in DEFAULT_CONFIG.items():
|
||||
data.setdefault(k, v)
|
||||
cfg = data
|
||||
return data
|
||||
except Exception as exc:
|
||||
log.warning("Failed to load config: %s", repr(exc))
|
||||
if cfg is None:
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
_apply_ui_language(cfg)
|
||||
return cfg
|
||||
log.warning("Failed to load config: %s", exc)
|
||||
return dict(DEFAULT_CONFIG)
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
@@ -221,9 +148,13 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
logging.getLogger('asyncio').setLevel(logging.WARNING)
|
||||
|
||||
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
str(LOG_FILE),
|
||||
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
|
||||
backupCount=0,
|
||||
encoding="utf-8",
|
||||
)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
|
||||
root.addHandler(fh)
|
||||
@@ -294,7 +225,7 @@ _proxy_thread: Optional[threading.Thread] = None
|
||||
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
|
||||
|
||||
|
||||
def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
|
||||
global _async_stop
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
@@ -303,14 +234,16 @@ def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||
_async_stop = (loop, stop_ev)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(_run(stop_event=stop_ev))
|
||||
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
|
||||
except Exception as exc:
|
||||
log.error("Proxy thread crashed: %s", repr(exc))
|
||||
msg, diagnose_called = diagnose_listen_error(exc)
|
||||
if msg:
|
||||
show_error(msg)
|
||||
if diagnose_called:
|
||||
diagnose_called()
|
||||
log.error("Proxy thread crashed: %s", exc)
|
||||
if "Address already in use" in str(exc) or "10048" in str(exc):
|
||||
on_port_busy(
|
||||
"Не удалось запустить прокси:\n"
|
||||
"Порт уже используется другим приложением.\n\n"
|
||||
"Закройте приложение, использующее этот порт, "
|
||||
"или измените порт в настройках прокси и перезапустите."
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
_async_stop = None
|
||||
@@ -319,22 +252,18 @@ def _run_proxy_thread(show_error: Callable[[str], None]) -> None:
|
||||
def apply_proxy_config(cfg: dict) -> bool:
|
||||
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
|
||||
try:
|
||||
dc_redirects = parse_dc_ip_list(dc_ip_list)
|
||||
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
|
||||
except ValueError as e:
|
||||
log.error("Bad config dc_ip: %s", e)
|
||||
return False
|
||||
|
||||
pc = proxy_config
|
||||
pc = tg_ws_proxy.proxy_config
|
||||
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||
pc.dc_redirects = dc_redirects
|
||||
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.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||
pc.cfproxy_user_domains = coerce_domain_list(cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"]))
|
||||
pc.cfproxy_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
|
||||
|
||||
|
||||
@@ -345,11 +274,10 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||
return
|
||||
|
||||
if not apply_proxy_config(cfg):
|
||||
from ui.i18n import t
|
||||
on_error(t("error.dc_config"))
|
||||
on_error("Ошибка конфигурации DC → IP.")
|
||||
return
|
||||
|
||||
pc = proxy_config
|
||||
pc = tg_ws_proxy.proxy_config
|
||||
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
|
||||
_proxy_thread = threading.Thread(
|
||||
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
|
||||
@@ -364,9 +292,6 @@ def stop_proxy() -> None:
|
||||
loop.call_soon_threadsafe(stop_ev.set)
|
||||
if _proxy_thread:
|
||||
_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
|
||||
log.info("Proxy stopped")
|
||||
|
||||
@@ -374,7 +299,7 @@ def stop_proxy() -> None:
|
||||
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
|
||||
log.info("Restarting proxy...")
|
||||
stop_proxy()
|
||||
time.sleep(1.0)
|
||||
time.sleep(0.3)
|
||||
start_proxy(cfg, on_error)
|
||||
|
||||
|
||||
@@ -382,10 +307,23 @@ def tg_proxy_url(cfg: dict) -> str:
|
||||
host = cfg.get("host", DEFAULT_CONFIG["host"])
|
||||
port = cfg.get("port", DEFAULT_CONFIG["port"])
|
||||
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
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:
|
||||
try:
|
||||
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
|
||||
@@ -408,10 +346,8 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
||||
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
|
||||
return
|
||||
IPV6_WARN_MARKER.touch()
|
||||
from ui.i18n import t
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: show_info(t("ipv6.warning"), t("app.name")),
|
||||
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
@@ -440,15 +376,13 @@ def maybe_notify_update(
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
from ui.i18n import t
|
||||
|
||||
if ask_open(
|
||||
t("update.ask_open", version=ver),
|
||||
t("app.update_title"),
|
||||
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
|
||||
"TG WS Proxy — обновление",
|
||||
):
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", repr(exc))
|
||||
log.debug("Update check failed: %s", exc)
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||
|
||||
@@ -459,7 +393,7 @@ _ctk_root: Any = None
|
||||
_ctk_root_ready = threading.Event()
|
||||
|
||||
|
||||
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||
def ensure_ctk_thread(ctk: Any) -> bool:
|
||||
global _ctk_root
|
||||
if ctk is None:
|
||||
return False
|
||||
@@ -471,7 +405,7 @@ def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
|
||||
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
|
||||
|
||||
install_tkinter_variable_del_guard()
|
||||
apply_ctk_appearance(ctk, mode)
|
||||
apply_ctk_appearance(ctk)
|
||||
_ctk_root = ctk.CTk()
|
||||
_ctk_root.withdraw()
|
||||
_ctk_root_ready.set()
|
||||
|
||||
+12
-113
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Проверка новой версии через GitHub Releases API
|
||||
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
|
||||
|
||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||
@@ -7,18 +7,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request
|
||||
from proxy.utils import build_github_opener
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPO = "Flowseal/tg-ws-proxy"
|
||||
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"
|
||||
|
||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
||||
@@ -31,14 +30,18 @@ _state: Dict[str, Any] = {
|
||||
"latest": None,
|
||||
"html_url": None,
|
||||
"error": None,
|
||||
"assets": [],
|
||||
}
|
||||
|
||||
|
||||
def _cache_file() -> Optional[Path]:
|
||||
try:
|
||||
from utils.tray_common import APP_DIR
|
||||
root = APP_DIR
|
||||
if sys.platform == "win32":
|
||||
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy"
|
||||
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)
|
||||
return root / ".update_check_cache.json"
|
||||
except OSError:
|
||||
@@ -69,7 +72,7 @@ def _parse_version_tuple(s: str) -> tuple:
|
||||
return (0,)
|
||||
parts = []
|
||||
for seg in s.split("."):
|
||||
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
|
||||
digits = "".join(c for c in seg if c.isdigit())
|
||||
if digits:
|
||||
try:
|
||||
parts.append(int(digits))
|
||||
@@ -131,7 +134,7 @@ def fetch_latest_release(
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
code = getattr(resp, "status", None) or resp.getcode()
|
||||
new_etag = resp.headers.get("ETag")
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
@@ -159,7 +162,6 @@ def run_check(current_version: str) -> None:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
if tag:
|
||||
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
return
|
||||
err = cache.get("last_error")
|
||||
_state["error"] = (
|
||||
@@ -179,7 +181,6 @@ def run_check(current_version: str) -> None:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
_apply_release_tag(tag, url, current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
if new_etag:
|
||||
cache["etag"] = new_etag
|
||||
_save_cache(cache_path, cache)
|
||||
@@ -199,13 +200,6 @@ def run_check(current_version: str) -> None:
|
||||
cache["etag"] = new_etag
|
||||
cache["tag_name"] = tag
|
||||
cache["html_url"] = html_url
|
||||
assets = [
|
||||
{"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")
|
||||
]
|
||||
_state["assets"] = assets
|
||||
cache["assets"] = assets
|
||||
cache.pop("last_error", None)
|
||||
_save_cache(cache_path, cache)
|
||||
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
|
||||
@@ -224,101 +218,6 @@ def run_check(current_version: str) -> None:
|
||||
_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]:
|
||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||
return dict(_state)
|
||||
|
||||
|
||||
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
||||
new_assets = _state.get("assets") or []
|
||||
if not new_assets:
|
||||
return None
|
||||
|
||||
target_name = None
|
||||
|
||||
# SHA256 match
|
||||
try:
|
||||
import hashlib
|
||||
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||
if code == 200 and data:
|
||||
cur_assets = _extract_assets(data)
|
||||
if cur_assets:
|
||||
h = hashlib.sha256()
|
||||
with open(exe_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
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:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||
import platform
|
||||
import struct
|
||||
|
||||
is_64 = struct.calcsize("P") * 8 == 64
|
||||
machine = platform.machine().lower()
|
||||
is_arm64 = machine in ("arm64", "aarch64")
|
||||
|
||||
try:
|
||||
is_modern = sys.getwindowsversion().major >= 10
|
||||
except Exception:
|
||||
is_modern = True
|
||||
|
||||
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 None
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
def is_windows_dark_theme() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
|
||||
try:
|
||||
import winreg
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
return value == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def apply_windows_dark_theme() -> None:
|
||||
try:
|
||||
import ctypes
|
||||
uxtheme = ctypes.windll.uxtheme
|
||||
|
||||
try:
|
||||
set_preferred = uxtheme[135]
|
||||
result = set_preferred(2)
|
||||
if result == 0:
|
||||
flush = uxtheme[136]
|
||||
flush()
|
||||
except Exception:
|
||||
try:
|
||||
allow_dark = uxtheme[135]
|
||||
allow_dark(True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
+43
-363
@@ -2,17 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
import winreg
|
||||
import tempfile
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from proxy.utils import build_github_opener
|
||||
|
||||
try:
|
||||
import pyperclip
|
||||
@@ -34,17 +30,13 @@ try:
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
from proxy import get_link_host
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
|
||||
from utils.win32_theme import (
|
||||
is_windows_dark_theme,
|
||||
apply_windows_dark_theme,
|
||||
)
|
||||
from utils.tray_common import (
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
||||
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
||||
quit_ctk, release_lock, restart_proxy,
|
||||
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
|
||||
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||
)
|
||||
from ui.ctk_tray_ui import (
|
||||
@@ -56,44 +48,10 @@ from ui.ctk_theme import (
|
||||
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
|
||||
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
|
||||
)
|
||||
from ui.i18n import set_language, t
|
||||
|
||||
_tray_icon: Optional[object] = None
|
||||
_config: dict = {}
|
||||
_exiting = False
|
||||
_win_mutex_handle = None
|
||||
|
||||
_ERROR_ALREADY_EXISTS = 183
|
||||
|
||||
|
||||
def _acquire_win_mutex() -> bool | None:
|
||||
global _win_mutex_handle
|
||||
try:
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
||||
kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p]
|
||||
handle = kernel32.CreateMutexW(None, True, "Local\\TgWsProxy_SingleInstance")
|
||||
if kernel32.GetLastError() == _ERROR_ALREADY_EXISTS:
|
||||
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
||||
return False
|
||||
if not handle:
|
||||
return None
|
||||
_win_mutex_handle = handle
|
||||
return True
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _release_win_mutex() -> None:
|
||||
global _win_mutex_handle
|
||||
if _win_mutex_handle:
|
||||
try:
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.ReleaseMutex(ctypes.c_void_p(_win_mutex_handle))
|
||||
kernel32.CloseHandle(ctypes.c_void_p(_win_mutex_handle))
|
||||
except Exception:
|
||||
pass
|
||||
_win_mutex_handle = None
|
||||
|
||||
ICON_PATH = str(Path(__file__).parent / "icon.ico")
|
||||
|
||||
@@ -106,247 +64,19 @@ _u32.MessageBoxW.restype = ctypes.c_int
|
||||
_MB_OK_ERR = 0x10
|
||||
_MB_OK_INFO = 0x40
|
||||
_MB_YESNO_Q = 0x24
|
||||
_MB_YESNOCANCEL_Q = 0x23
|
||||
_IDYES = 6
|
||||
_IDNO = 7
|
||||
|
||||
|
||||
def _show_error(text: str, title: Optional[str] = None) -> None:
|
||||
_u32.MessageBoxW(None, text, title or t("app.error_title"), _MB_OK_ERR)
|
||||
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
|
||||
_u32.MessageBoxW(None, text, title, _MB_OK_ERR)
|
||||
|
||||
|
||||
def _show_info(text: str, title: Optional[str] = None) -> None:
|
||||
_u32.MessageBoxW(None, text, title or t("app.name"), _MB_OK_INFO)
|
||||
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
|
||||
_u32.MessageBoxW(None, text, title, _MB_OK_INFO)
|
||||
|
||||
|
||||
def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||
return _u32.MessageBoxW(None, text, title or t("app.name"), _MB_YESNO_Q) == _IDYES
|
||||
|
||||
|
||||
def update_ctk_form(
|
||||
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
|
||||
release_url: Optional[str] = None,
|
||||
) -> str:
|
||||
title = title or t("app.name")
|
||||
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
||||
if result == _IDYES:
|
||||
return "update"
|
||||
if result == _IDNO:
|
||||
return "open"
|
||||
return "close"
|
||||
|
||||
result = {"value": "close"}
|
||||
|
||||
def _build(done: threading.Event) -> None:
|
||||
theme = ctk_theme_for_platform()
|
||||
root = create_ctk_toplevel(
|
||||
ctk,
|
||||
title=title,
|
||||
width=310 if IS_FROZEN else 210,
|
||||
height=130 if IS_FROZEN else 100,
|
||||
theme=theme,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
|
||||
|
||||
ctk.CTkLabel(
|
||||
frame,
|
||||
text=text,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
wraplength=270,
|
||||
font=(theme.ui_font_family, 12),
|
||||
text_color=theme.text_primary,
|
||||
).pack(fill="x", pady=(0, 10))
|
||||
|
||||
row = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
row.pack(fill="x")
|
||||
|
||||
status_label = ctk.CTkLabel(
|
||||
frame, text="", justify="left", anchor="w", wraplength=270,
|
||||
font=(theme.ui_font_family, 11), text_color=theme.text_secondary,
|
||||
)
|
||||
status_label.pack(fill="x", pady=(6, 0))
|
||||
|
||||
btns: list = []
|
||||
|
||||
def _set_status(msg: str) -> None:
|
||||
root.after(0, lambda: status_label.configure(text=msg))
|
||||
|
||||
def _close_with(value: str) -> None:
|
||||
result["value"] = value
|
||||
root.destroy()
|
||||
done.set()
|
||||
|
||||
def _on_update() -> None:
|
||||
if not download_url:
|
||||
if release_url:
|
||||
webbrowser.open(release_url)
|
||||
_close_with("open")
|
||||
return
|
||||
for b in btns:
|
||||
b.configure(state="disabled")
|
||||
root.protocol("WM_DELETE_WINDOW", lambda: None)
|
||||
def _run():
|
||||
_perform_update(download_url, set_status=_set_status)
|
||||
root.after(0, lambda: [b.configure(state="normal") for b in btns])
|
||||
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
if IS_FROZEN:
|
||||
btn_upd = ctk.CTkButton(
|
||||
row, text=t("button.update"), width=88, height=34,
|
||||
font=(theme.ui_font_family, 13), command=_on_update,
|
||||
)
|
||||
btn_upd.pack(side="left", padx=(0, 6))
|
||||
btns.append(btn_upd)
|
||||
btn_pg = ctk.CTkButton(
|
||||
row, text=t("button.page"), width=88, height=34,
|
||||
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
|
||||
)
|
||||
btn_pg.pack(side="left", padx=(0, 6))
|
||||
btns.append(btn_pg)
|
||||
btn_cl = ctk.CTkButton(
|
||||
row, text=t("button.close"), width=88, height=34,
|
||||
font=(theme.ui_font_family, 13),
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1, border_color=theme.field_border,
|
||||
command=lambda: _close_with("close"),
|
||||
)
|
||||
btn_cl.pack(side="left")
|
||||
btns.append(btn_cl)
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))
|
||||
|
||||
ctk_run_dialog(_build)
|
||||
return result["value"]
|
||||
|
||||
|
||||
def _perform_update(download_url: str, set_status=None) -> None:
|
||||
def _step(msg: str) -> None:
|
||||
log.info("Update: %s", msg)
|
||||
if set_status:
|
||||
set_status(msg)
|
||||
time.sleep(0.8)
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
log.error("Update error: %s", msg)
|
||||
if set_status:
|
||||
set_status(f"{t('update.error', msg=msg)}")
|
||||
else:
|
||||
_show_error(msg)
|
||||
|
||||
_step(t("update.downloading"))
|
||||
cur_exe = Path(sys.executable)
|
||||
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
|
||||
tmp_path = None
|
||||
try:
|
||||
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
log.info("Downloading update from %s", download_url)
|
||||
opener = build_github_opener()
|
||||
with opener.open(download_url) as _resp:
|
||||
with open(str(tmp_path), "wb") as _fout:
|
||||
while True:
|
||||
_chunk = _resp.read(65536)
|
||||
if not _chunk:
|
||||
break
|
||||
_fout.write(_chunk)
|
||||
except Exception as exc:
|
||||
_err(t("update.download_fail", error=exc))
|
||||
if tmp_path:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
_step(t("update.replacing"))
|
||||
try:
|
||||
if old_exe.exists():
|
||||
old_exe.unlink()
|
||||
cur_exe.rename(old_exe)
|
||||
except Exception as exc:
|
||||
_err(t("update.rename_fail", error=exc))
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
tmp_path.rename(cur_exe)
|
||||
except Exception as exc:
|
||||
_err(t("update.move_fail", error=exc))
|
||||
try:
|
||||
old_exe.rename(cur_exe)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
_step(t("update.restarting"))
|
||||
_release_win_mutex()
|
||||
stop_proxy()
|
||||
|
||||
# Don't reuse existing _MEI* dir
|
||||
env = os.environ.copy()
|
||||
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
|
||||
del env[_k]
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
|
||||
env["PATH"] = os.pathsep.join(
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if os.path.normcase(p.rstrip("\\/")) != _mei
|
||||
)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(cur_exe)],
|
||||
env=env,
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to launch updated exe: %s", exc)
|
||||
time.sleep(0.5)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
||||
if not cfg.get("check_updates", True):
|
||||
return
|
||||
|
||||
def _work():
|
||||
time.sleep(1.5)
|
||||
if is_exiting():
|
||||
return
|
||||
try:
|
||||
from proxy import __version__
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
|
||||
|
||||
run_check(__version__)
|
||||
st = get_status()
|
||||
if not st.get("has_update") or is_exiting():
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||
choice = update_ctk_form(
|
||||
t("update.available", version=ver),
|
||||
download_url=asset[0] if asset else None,
|
||||
release_url=url,
|
||||
)
|
||||
if choice == "open":
|
||||
webbrowser.open(url)
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", repr(exc))
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
|
||||
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
|
||||
|
||||
|
||||
# autostart (registry)
|
||||
@@ -384,7 +114,9 @@ def set_autostart_enabled(enabled: bool) -> None:
|
||||
except OSError as exc:
|
||||
log.error("Failed to update autostart: %s", exc)
|
||||
_show_error(
|
||||
t("dialog.autostart_fail", error=exc)
|
||||
"Не удалось изменить автозапуск.\n\n"
|
||||
"Попробуйте запустить приложение от имени пользователя "
|
||||
f"с правами на реестр.\n\nОшибка: {exc}"
|
||||
)
|
||||
|
||||
|
||||
@@ -400,30 +132,34 @@ def _on_open_in_telegram(icon=None, item=None) -> None:
|
||||
log.info("Browser open failed, copying to clipboard")
|
||||
if pyperclip is None:
|
||||
_show_error(
|
||||
t("dialog.open_tg_fail_manual", url=url)
|
||||
"Не удалось открыть Telegram автоматически.\n\n"
|
||||
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
pyperclip.copy(url)
|
||||
_show_info(
|
||||
t("dialog.open_tg_fail_clipboard", url=url)
|
||||
"Не удалось открыть Telegram автоматически.\n\n"
|
||||
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Clipboard copy failed: %s", exc)
|
||||
_show_error(t("dialog.copy_fail", error=exc))
|
||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
||||
|
||||
|
||||
def _on_copy_link(icon=None, item=None) -> None:
|
||||
url = tg_proxy_url(_config)
|
||||
log.info("Copying link: %s", url)
|
||||
if pyperclip is None:
|
||||
_show_error(t("dialog.pyperclip_missing"))
|
||||
_show_error(
|
||||
"Установите пакет pyperclip для копирования в буфер обмена."
|
||||
)
|
||||
return
|
||||
try:
|
||||
pyperclip.copy(url)
|
||||
except Exception as exc:
|
||||
log.error("Clipboard copy failed: %s", exc)
|
||||
_show_error(t("dialog.copy_fail", error=exc))
|
||||
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
|
||||
|
||||
|
||||
def _on_restart(icon=None, item=None) -> None:
|
||||
@@ -439,13 +175,9 @@ def _on_edit_config(icon=None, item=None) -> None:
|
||||
def _on_open_logs(icon=None, item=None) -> None:
|
||||
log.info("Opening log file: %s", LOG_FILE)
|
||||
if LOG_FILE.exists():
|
||||
try:
|
||||
os.startfile(str(LOG_FILE))
|
||||
except Exception as exc:
|
||||
log.error("Failed to open log file: %s", exc)
|
||||
_show_error(t("dialog.log_open_fail", error=exc))
|
||||
os.startfile(str(LOG_FILE))
|
||||
else:
|
||||
_show_info(t("dialog.log_not_found"))
|
||||
_show_info("Файл логов ещё не создан.")
|
||||
|
||||
|
||||
def _on_exit(icon=None, item=None) -> None:
|
||||
@@ -464,8 +196,8 @@ def _on_exit(icon=None, item=None) -> None:
|
||||
# settings dialog
|
||||
|
||||
def _edit_config_dialog() -> None:
|
||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
_show_error(t("dialog.ctk_missing"))
|
||||
if not ensure_ctk_thread(ctk):
|
||||
_show_error("customtkinter не установлен.")
|
||||
return
|
||||
|
||||
cfg = dict(_config)
|
||||
@@ -480,80 +212,46 @@ def _edit_config_dialog() -> None:
|
||||
h += 100
|
||||
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
|
||||
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
|
||||
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
|
||||
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(
|
||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||
show_autostart=_supports_autostart(),
|
||||
autostart_value=cfg.get("autostart", False),
|
||||
on_language_change=_refresh_tray_menu,
|
||||
)
|
||||
|
||||
_original_appearance = ctk.get_appearance_mode()
|
||||
|
||||
def _restore_ui_locale() -> None:
|
||||
set_language(_original_language)
|
||||
_refresh_tray_menu()
|
||||
|
||||
def _finish() -> None:
|
||||
root.destroy()
|
||||
done.set()
|
||||
|
||||
def _cancel() -> None:
|
||||
ctk.set_appearance_mode(_original_appearance)
|
||||
_restore_ui_locale()
|
||||
_finish()
|
||||
|
||||
def on_save() -> None:
|
||||
from tkinter import messagebox
|
||||
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
|
||||
if isinstance(merged, str):
|
||||
messagebox.showerror(t("app.error_title"), merged, parent=root)
|
||||
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
|
||||
return
|
||||
|
||||
_ui_only_keys = {"appearance", "autostart", "check_updates", "language"}
|
||||
config_changed = any(merged.get(k) != _config.get(k) for k in merged)
|
||||
proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys)
|
||||
|
||||
if not config_changed:
|
||||
_restore_ui_locale()
|
||||
_finish()
|
||||
return
|
||||
|
||||
save_config(merged)
|
||||
_config.update(merged)
|
||||
set_language(merged.get("language", DEFAULT_CONFIG["language"]))
|
||||
log.info("Config saved: %s", merged)
|
||||
if _supports_autostart():
|
||||
set_autostart_enabled(bool(merged.get("autostart", False)))
|
||||
_tray_icon.menu = _build_menu()
|
||||
|
||||
if not proxy_changed:
|
||||
_finish()
|
||||
return
|
||||
|
||||
do_restart = messagebox.askyesno(
|
||||
t("dialog.restart_title"),
|
||||
t("dialog.restart_body"),
|
||||
"Перезапустить?",
|
||||
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
|
||||
parent=root,
|
||||
)
|
||||
_finish()
|
||||
if do_restart:
|
||||
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", _cancel)
|
||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
|
||||
root.protocol("WM_DELETE_WINDOW", _finish)
|
||||
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
|
||||
|
||||
ctk_run_dialog(_build)
|
||||
|
||||
@@ -564,7 +262,7 @@ def _show_first_run() -> None:
|
||||
ensure_dirs()
|
||||
if FIRST_RUN_MARKER.exists():
|
||||
return
|
||||
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
if not ensure_ctk_thread(ctk):
|
||||
FIRST_RUN_MARKER.touch()
|
||||
return
|
||||
|
||||
@@ -576,7 +274,7 @@ def _show_first_run() -> None:
|
||||
theme = ctk_theme_for_platform()
|
||||
w, h = FIRST_RUN_SIZE
|
||||
root = create_ctk_toplevel(
|
||||
ctk, title=t("app.name"), width=w, height=h, theme=theme,
|
||||
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
|
||||
@@ -599,16 +297,16 @@ def _build_menu():
|
||||
return None
|
||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
link_host = get_link_host(host)
|
||||
link_host = tg_ws_proxy.get_link_host(host)
|
||||
return pystray.Menu(
|
||||
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
|
||||
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(t("tray.restart"), _on_restart),
|
||||
pystray.MenuItem(t("tray.settings"), _on_edit_config),
|
||||
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||
pystray.MenuItem("Перезапустить прокси", _on_restart),
|
||||
pystray.MenuItem("Настройки...", _on_edit_config),
|
||||
pystray.MenuItem("Открыть логи", _on_open_logs),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||
pystray.MenuItem("Выход", _on_exit),
|
||||
)
|
||||
|
||||
|
||||
@@ -618,10 +316,6 @@ def run_tray() -> None:
|
||||
global _tray_icon, _config
|
||||
|
||||
_config = load_config()
|
||||
|
||||
if is_windows_dark_theme():
|
||||
apply_windows_dark_theme()
|
||||
|
||||
bootstrap(_config)
|
||||
|
||||
if pystray is None or Image is None or ctk is None:
|
||||
@@ -635,11 +329,11 @@ def run_tray() -> None:
|
||||
return
|
||||
|
||||
start_proxy(_config, _show_error)
|
||||
_maybe_do_update(_config, lambda: _exiting)
|
||||
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
|
||||
_show_first_run()
|
||||
check_ipv6_warning(_show_info)
|
||||
|
||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu())
|
||||
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
|
||||
log.info("Tray icon running")
|
||||
_tray_icon.run()
|
||||
|
||||
@@ -648,27 +342,13 @@ def run_tray() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
|
||||
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
|
||||
if not acquire_lock("windows.py"):
|
||||
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
|
||||
return
|
||||
|
||||
if IS_FROZEN:
|
||||
def _cleanup_old_exes():
|
||||
exe_dir = Path(sys.executable).parent
|
||||
time.sleep(3)
|
||||
for _f in exe_dir.glob("*_oldtgws.exe"):
|
||||
try:
|
||||
_f.unlink()
|
||||
log.info("Deleted leftover: %s", _f)
|
||||
except OSError:
|
||||
pass
|
||||
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
|
||||
|
||||
try:
|
||||
run_tray()
|
||||
finally:
|
||||
release_lock()
|
||||
_release_win_mutex()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user