Compare commits

..

98 Commits

Author SHA1 Message Date
Flowseal e46cf138ac new domains 2026-05-08 09:35:17 +03:00
Flowseal 145b0c431a version bump 2026-05-08 09:32:07 +03:00
Flowseal b991948a77 remove labels 2026-05-08 09:31:50 +03:00
Flowseal 42df9cfdc4 triage update 2026-05-08 09:31:26 +03:00
Flowseal eabc0c9a15 docs update 2026-05-08 09:22:56 +03:00
Flowseal ea88adc2dc rpm build fixes 2026-05-08 09:19:23 +03:00
Sylvester Alderson f554f730c2 linux rpm package, github actions (#726) 2026-05-08 09:16:54 +03:00
deexsed f85c4fe854 fix(config): добавлена валидация и надежный fallback для CF domain list (#799) 2026-05-08 08:59:03 +03:00
Proger b070647996 support TG_WS_PROXY_SECRET (#769) 2026-05-08 08:54:45 +03:00
Konukhov Yaroslav e3d2229f7f Мелкие фиксы настроек и пара багов (#805) 2026-05-08 08:54:30 +03:00
deexsed 1cbc7dee7d Улучшена "архитектура" документации и настроен triage для issues (#801) 2026-05-08 08:51:40 +03:00
delewer 1728fabfc7 docs(README): update preview image source with better visual (#817) 2026-05-06 17:31:49 +03:00
deexsed bf08bad11e Fix --cfproxy-priority CLI parsing (#797) 2026-04-30 13:29:43 +03:00
Kleshzz a787b4fd6b Update README.md (#796) 2026-04-30 06:12:54 +03:00
Sanfix 2c577c1166 Update README.md (change service name in cli version) (#783) 2026-04-28 17:38:37 +03:00
delewer 05b22fe3ba docs(README): fix little typo bug (#778) 2026-04-26 18:54:02 +03:00
Flowseal cc00c6d040 Version bump 2026-04-26 16:58:48 +03:00
Flowseal b3ed5c09db Windows auto update 2026-04-26 16:58:17 +03:00
Flowseal b8556dc702 fix #775 2026-04-26 16:26:50 +03:00
Flowseal 28be00ea9e docs update 2026-04-19 17:32:54 +03:00
Flowseal 5795de00b1 Version bump 2026-04-18 18:59:46 +03:00
Flowseal c5fa5b7f3e fix: cfproxy user domain not set via CLI #741 2026-04-18 18:59:16 +03:00
Flowseal a70e50b9f3 refactor 2026-04-18 16:58:49 +03:00
Flowseal 059ca8760f moved some dubug logs to warning level 2026-04-18 15:49:42 +03:00
Flowseal 0c8d0f160a better exception logging 2026-04-18 15:45:15 +03:00
Flowseal 791708cc3d ws_blacklsit annotation fix 2026-04-18 15:25:11 +03:00
Flowseal 1abcbf86fe gitignore clear 2026-04-18 15:23:56 +03:00
Flowseal d84b9eadc4 version fix 2026-04-16 18:20:47 +03:00
Flowseal c1b4cb0204 docs update 2026-04-16 18:01:48 +03:00
Flowseal 5d08e16e5d removed repeated annotation 2026-04-16 17:56:48 +03:00
Flowseal a844a88f38 docs update 2026-04-16 17:52:58 +03:00
Flowseal e5f1d02737 docs links update 2026-04-16 17:51:41 +03:00
Flowseal 3a6e82c2a8 docs update 2026-04-16 17:50:32 +03:00
Flowseal e56ada1a34 CF domains balancer 2026-04-16 17:08:03 +03:00
Flowseal b44d79a933 docs update 2026-04-16 17:08:03 +03:00
Aksarin Mikhail 77723d875f Update README.md (#711)
Fix relative links
2026-04-16 00:29:58 +03:00
Flowseal 548ec05fc5 docs update 2026-04-14 21:56:14 +03:00
Flowseal 03c7719c39 mutex check simplify 2026-04-14 16:58:54 +03:00
Flowseal db4cebe0b2 build test 2026-04-14 16:51:26 +03:00
Flowseal ca81d037f7 docs update 2026-04-14 03:11:13 +03:00
Flowseal 07615af49c bootloader build fix 2026-04-14 02:44:15 +03:00
Flowseal f8ee37370d Version bump 2026-04-14 00:27:27 +03:00
Flowseal 4cbb9e555c windows mutex-lock 2026-04-14 00:27:27 +03:00
Flowseal 25ae4b0a24 build version changes 2026-04-14 00:27:27 +03:00
Kleshzz 8af1bc8c89 Add .gitattributes & Update .gitignore (#690) 2026-04-13 19:30:57 +03:00
Flowseal b48ac67b9f donate web link 2026-04-11 21:27:21 +03:00
Flowseal 937acdb461 Version bump 2026-04-11 21:09:46 +03:00
Flowseal 6f3da84e48 Refresh domains schedule 2026-04-11 21:09:08 +03:00
Flowseal 3c3e9eb34b fix domains testing 2026-04-11 21:03:53 +03:00
Flowseal ba89cad8b8 fake-tls cli 2026-04-11 20:52:24 +03:00
Flowseal bf905ec54f docs update 2026-04-11 19:11:47 +03:00
Flowseal ace0a5e968 docs update 2026-04-11 18:54:32 +03:00
Flowseal e47eef4709 docs update 2026-04-11 15:28:37 +03:00
Flowseal abe1d1f01e docs update 2026-04-11 15:28:37 +03:00
Flowseal cc31c02c9d donate button ctk 2026-04-11 15:28:37 +03:00
Flowseal f39bb15ff6 docs update 2026-04-11 15:28:31 +03:00
kreker06 5a62cd82b2 Update Dockerfile (#586) 2026-04-11 14:54:32 +03:00
Flowseal fe4e0e8234 docs update 2026-04-10 19:28:36 +03:00
Flowseal 172dc67093 docs update 2026-04-10 02:57:25 +03:00
Flowseal c5c2907fa8 docs update 2026-04-10 02:23:18 +03:00
Flowseal 26b95ffa0f Version bump 2026-04-10 01:48:07 +03:00
Flowseal 3dfcc27932 remove caching for domains check 2026-04-10 00:59:43 +03:00
Flowseal 6e0e567790 new domain 2026-04-10 00:56:57 +03:00
Flowseal bc79a5e4c1 possible #626 ref 2026-04-10 00:37:27 +03:00
Flowseal ce83b78bac small fixes 2026-04-10 00:22:45 +03:00
Flowseal a6235f3594 prettify 2026-04-09 23:51:18 +03:00
Flowseal c0d9b5f8e1 refactoring 2026-04-09 23:43:06 +03:00
Flowseal 4041fd9f05 unpack bug fix 2026-04-09 23:20:32 +03:00
Flowseal dd09f24449 multiple domains handling 2026-04-09 23:12:17 +03:00
Flowseal dd666489e3 theme combobox 2026-04-09 20:10:48 +03:00
Flowseal 3af0cd75a2 update imports after refactor 2026-04-09 19:55:12 +03:00
Flowseal 535d4126ed refactoring 2026-04-09 19:54:38 +03:00
Flowseal 44e754ded0 exclude not needed modules 2026-04-09 15:45:11 +03:00
Flowseal 71be4461d3 cfproxy typo 2026-04-08 02:17:21 +03:00
Flowseal 9279399f00 readme typo 2026-04-08 02:16:35 +03:00
Flowseal 557c92b9a3 docs upd 2026-04-08 02:15:39 +03:00
Flowseal c883674ad0 dc203 ip change 2026-04-08 02:09:03 +03:00
Flowseal df98baf961 and another one 2026-04-08 00:36:12 +03:00
Flowseal 34dde32033 and another one 2026-04-08 00:25:41 +03:00
Flowseal b8bd062663 git actions compile test 2026-04-08 00:18:38 +03:00
Flowseal 8e1e3fcc45 bootloader recompile test 2026-04-08 00:11:07 +03:00
Flowseal 097bb9d0b7 version bump 2026-04-08 00:00:17 +03:00
Flowseal 19fbf7494a pyinstaller version update 2026-04-08 00:00:02 +03:00
Flowseal 4b0bc2f4d2 dc203 override hardcode 2026-04-07 23:53:58 +03:00
Flowseal 7850e1f5b4 pool reset on restart 2026-04-07 23:52:55 +03:00
Flowseal 63d5bafd3e docs upd 2026-04-07 18:11:26 +03:00
Flowseal 7eaba0b29c docs upd 2026-04-07 18:06:49 +03:00
Flowseal 6c94d3a39d tip block 2026-04-07 17:51:59 +03:00
Flowseal 746cd66b35 build fixes 2026-04-07 17:15:30 +03:00
Flowseal e5d8ff7769 version changing & readme update 2026-04-07 17:12:29 +03:00
Flowseal 3ee82e5114 typos 2026-04-07 17:07:41 +03:00
Qirashi db1308e3f5 Tray dark theme (#591) 2026-04-07 17:06:21 +03:00
Flowseal 6231499c39 lock fixes 2026-04-07 17:04:01 +03:00
Flowseal 826554abfb CfProxy UI setup 2026-04-07 17:04:01 +03:00
Flowseal 7f44c524c8 lists clear on restart 2026-04-07 17:04:01 +03:00
Flowseal 6310fcd6eb docs 2026-04-07 17:03:01 +03:00
Flowseal 081b150b3d Removed dc overriding 2026-04-07 17:02:13 +03:00
Flowseal 15001980dc cloudflare proxy; closes #576 2026-04-07 17:02:13 +03:00
80 changed files with 3090 additions and 5193 deletions
+9
View File
@@ -0,0 +1,9 @@
* text=auto eol=lf
*.py text diff=python
*.spec text linguist-language=Python
*.toml text
*.txt text
*.ico binary
+11
View File
@@ -0,0 +1,11 @@
# Default owners
* @Flowseal
# Automation and repository settings
.github/** @Flowseal
# Documentation
docs/** @Flowseal
# Core proxy implementation
proxy/** @Flowseal
+1
View File
@@ -0,0 +1 @@
custom: ['https://nowpayments.io/donation/flowseal']
+11 -8
View File
@@ -4,17 +4,20 @@ description: Сообщить о проблеме
labels: ['type: проблема', 'status: нуждается в сортировке']
body:
- type: textarea
id: description
- type: input
id: app_version
attributes:
label: Опишите вашу проблему
description: Чётко опишите проблему с которой вы столкнулись
placeholder: Описание проблемы
label: Версия TG WS Proxy
description: Укажите версию приложения (например, v1.2.3)
placeholder: vX.Y.Z
validations:
required: true
- type: textarea
id: additions
id: description
attributes:
label: Дополнительные детали
description: Если у вас проблемы с работой прокси, то приложите файл логов в момент возникновения проблемы.
label: Опишите вашу проблему
description: Чётко опишите проблему, с которой вы столкнулись
placeholder: Описание проблемы
validations:
required: true
+6
View File
@@ -0,0 +1,6 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Документация
url: https://github.com/Flowseal/tg-ws-proxy/tree/main/docs
about: Ознакомьтесь с документацией перед созданием issue
@@ -0,0 +1,37 @@
name: 🚀 Предложение
title: '[Предложение] '
description: Предложить улучшение или новую функциональность
labels: ['type: предложение', 'status: нуждается в сортировке']
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: Любые дополнительные материалы по предложению
+10
View File
@@ -0,0 +1,10 @@
virkgj.com
vmmzovy.com
mkuosckvso.com
zaewayzmplad.com
twdmbzcm.com
awzwsldi.com
clngqrflngqin.com
tjacxbqtj.com
bxaxtxmrw.com
dmohrsgmohcrwb.com
+127 -107
View File
@@ -16,9 +16,6 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-windows:
runs-on: windows-latest
@@ -29,18 +26,47 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
python-version: "3.11"
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: Install pyinstaller
run: pip install "pyinstaller==6.13.0"
- 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: 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
@@ -74,6 +100,26 @@ 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
@@ -312,6 +358,76 @@ 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
@@ -320,100 +436,10 @@ jobs:
path: |
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
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 }}
dist/TgWsProxy_linux_amd64.rpm
release:
needs: [build-windows, build-win7, build-macos, build-linux, build-android]
needs: [build-windows, build-win7, build-macos, build-linux]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }}
steps:
@@ -423,19 +449,14 @@ 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: |
## TG WS Proxy ${{ github.event.inputs.version }}
##
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
files: |
dist/TgWsProxy_windows.exe
dist/TgWsProxy_windows_7_64bit.exe
@@ -443,8 +464,7 @@ jobs:
dist/TgWsProxy_macos_universal.dmg
dist/TgWsProxy_linux_amd64
dist/TgWsProxy_linux_amd64.deb
dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk
dist/tg-ws-proxy-android-${{ github.event.inputs.version }}-legacy32.apk
dist/TgWsProxy_linux_amd64.rpm
draft: false
prerelease: false
env:
+41
View File
@@ -0,0 +1,41 @@
name: Auto comment on new issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
comment:
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
### Если проблема осталась, пожалуйста, приложите по возможности логи.
Сделать это можно через иконку в трее -> Пкм -> Открыть логи. Сохраните логи в файл и приложите его сюда.
+2 -17
View File
@@ -6,6 +6,8 @@ __pycache__/
dist/
build/
*.spec.bak
venv/
.venv/
# PyInstaller
*.manifest
@@ -16,27 +18,10 @@ build/
.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
+48
View File
@@ -0,0 +1,48 @@
# 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 проверяются и принимаются быстрее.
+3 -2
View File
@@ -24,6 +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"
RUN apt-get update \
@@ -35,11 +36,11 @@ RUN apt-get update \
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY proxy ./proxy
COPY README.md LICENSE ./
COPY docs/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; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; if [ -n \"${TG_WS_PROXY_SECRET}\" ]; then args=\"$args --secret ${TG_WS_PROXY_SECRET}\"; fi; exec /opt/venv/bin/python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD []
-273
View File
@@ -1,273 +0,0 @@
> [!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)
-170
View File
@@ -1,170 +0,0 @@
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")
}
-1
View File
@@ -1 +0,0 @@
# Intentionally empty for the initial Android shell.
-39
View File
@@ -1,39 +0,0 @@
<?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>
@@ -1,62 +0,0 @@
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))
}
}
}
@@ -1,53 +0,0 @@
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)
}
}
@@ -1,330 +0,0 @@
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)
}
}
@@ -1,156 +0,0 @@
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,
)
@@ -1,395 +0,0 @@
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,
)
@@ -1,49 +0,0 @@
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
}
}
@@ -1,62 +0,0 @@
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"
}
}
@@ -1,102 +0,0 @@
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,
)
@@ -1,23 +0,0 @@
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
}
}
}
@@ -1,161 +0,0 @@
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)
@@ -1,13 +0,0 @@
<?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>
@@ -1,13 +0,0 @@
<?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>
@@ -1,94 +0,0 @@
<?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>
@@ -1,424 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
@@ -1,75 +0,0 @@
<?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>
@@ -1,11 +0,0 @@
<?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>
-190
View File
@@ -1,190 +0,0 @@
#!/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
-5
View File
@@ -1,5 +0,0 @@
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
}
-6
View File
@@ -1,6 +0,0 @@
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
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
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
-249
View File
@@ -1,249 +0,0 @@
#!/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" "$@"
-92
View File
@@ -1,92 +0,0 @@
@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
-28
View File
@@ -1,28 +0,0 @@
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")
+75
View File
@@ -0,0 +1,75 @@
# Установка из исходников
## Консольный прокси
Для запуска только прокси без интерфейса системного трея достаточно базовой установки:
```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-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
| `--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
```
+32
View File
@@ -0,0 +1,32 @@
# 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) за информацию.
+52
View File
@@ -0,0 +1,52 @@
# 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>
```
+12
View File
@@ -0,0 +1,12 @@
> [!TIP]
>
> ### 🎉 Поддержать меня
>
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
Буду благодарен за любую форму поддержки! Спасибо ❤️
+51
View File
@@ -0,0 +1,51 @@
# 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
```
+30
View File
@@ -0,0 +1,30 @@
# 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
```
+125
View File
@@ -0,0 +1,125 @@
> [!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="https://github.com/user-attachments/assets/17f1d15e-e1c2-41ea-a452-220d13359262" media="(prefers-color-scheme: dark)">
<img src="https://github.com/user-attachments/assets/8d595468-83a1-4e4f-bac4-9ce4a07027bd">
</picture>
## Навигация
- **🚀 Быстрый старт**
- **[Windows](./README.windows.md)**
- **[macOS](./README.macos.md)**
- **[Linux](./README.linux.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+)
- `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+ для `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 для системного трея)
## Контрибьюторы
Спасибо всем, кто помогает развивать проект ❤️
<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)
+52
View File
@@ -0,0 +1,52 @@
# TG WS Proxy для Windows
Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте:
- `TgWsProxy_windows.exe` (Windows 10+)
- `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:** из настроек или логов
## Установка из исходников
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
```bash
pip install -e .
tg-ws-proxy-tray-win
```
+31
View File
@@ -0,0 +1,31 @@
# Файлы конфигурации 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_priority": true,
"cfproxy_user_domain": "",
"appearance": "auto"
}
```
Ключ `check_updates`: при `true` выполняется запрос к GitHub и сравнение текущей версии с последним релизом (только уведомление и ссылка на страницу загрузки).
На Windows в конфиге может быть `autostart` (автозапуск при входе в систему).
+26 -7
View File
@@ -12,7 +12,7 @@ import pyperclip
import pystray
from PIL import Image, ImageTk
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import get_link_host
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
@@ -138,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None:
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk):
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.")
return
@@ -156,21 +156,40 @@ def _edit_config_dialog() -> None:
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
_original_appearance = ctk.get_appearance_mode()
def _finish() -> None:
root.destroy()
done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_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("TG WS Proxy — Ошибка", merged, parent=root)
return
_ui_only_keys = {"appearance", "check_updates"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_finish()
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
@@ -180,8 +199,8 @@ def _edit_config_dialog() -> None:
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build)
@@ -193,7 +212,7 @@ def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
if not ensure_ctk_thread(ctk):
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
FIRST_RUN_MARKER.touch()
return
@@ -227,7 +246,7 @@ def _show_first_run() -> None:
def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
@@ -273,7 +292,7 @@ def run_tray() -> None:
def main() -> None:
if not acquire_lock("linux.py"):
if not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
try:
+33 -10
View File
@@ -24,8 +24,8 @@ try:
except ImportError:
pyperclip = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
from proxy.tg_ws_proxy import _run
from utils.tray_common import (
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
@@ -153,7 +153,7 @@ def _run_proxy_thread() -> None:
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
@@ -176,7 +176,7 @@ def _start_proxy() -> None:
if not apply_proxy_config(_config):
_show_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
pc = 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()
@@ -309,7 +309,7 @@ def _maybe_notify_update_async() -> None:
):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
log.warning("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
@@ -362,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:
tg_ws_proxy.parse_dc_ip_list(dc_lines)
parse_dc_ip_list(dc_lines)
except ValueError as e:
_show_error(str(e))
return
@@ -392,6 +392,26 @@ def _edit_config_dialog() -> None:
except ValueError:
pass
cfproxy = _ask_yes_no_close("Включить Cloudflare Proxy (CfProxy)?")
if cfproxy is None:
return
cfproxy_priority = True
if cfproxy:
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
if cfproxy_priority_result is None:
return
cfproxy_priority = cfproxy_priority_result
cfproxy_domain = _osascript_input(
"Свой CF-домен (оставьте пустым для автоматического выбора):\n"
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.",
cfg.get("cfproxy_user_domain", DEFAULT_CONFIG.get("cfproxy_user_domain", "")),
)
if cfproxy_domain is None:
return
cfproxy_domain = cfproxy_domain.strip()
new_cfg = {
"host": host,
"port": port,
@@ -402,6 +422,9 @@ 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_priority": cfproxy_priority,
"cfproxy_user_domain": cfproxy_domain,
}
save_config(new_cfg)
log.info("Config saved: %s", new_cfg)
@@ -427,7 +450,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 = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
text = (
f"Прокси запущен и работает в строке меню.\n\n"
@@ -496,7 +519,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
self._open_tg_item = rumps.MenuItem(
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
@@ -536,7 +559,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 = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
@@ -587,7 +610,7 @@ def run_menubar() -> None:
def main() -> None:
if not acquire_lock("macos.py"):
if not acquire_lock():
_show_info("Приложение уже запущено.")
return
try:
+15 -1
View File
@@ -46,11 +46,25 @@ a = Analysis(
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
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')]
+15 -1
View File
@@ -25,11 +25,25 @@ a = Analysis(
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
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
+36
View File
@@ -0,0 +1,36 @@
# 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, 6, 5, 0),
prodvers=(1, 6, 5, 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.6.6.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.6.6.0'),
]
)
]
),
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
]
)
+18 -2
View File
@@ -26,14 +26,29 @@ a = Analysis(
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
excludes=[
'PIL._avif',
'PIL._webp',
'PIL._imagingtk',
],
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')]
@@ -50,7 +65,7 @@ exe = EXE(
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
@@ -60,4 +75,5 @@ 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,
)
+6 -1
View File
@@ -1 +1,6 @@
__version__ = "1.4.0"
from .config import parse_dc_ip_list, proxy_config
from .utils import get_link_host
__version__ = "1.6.6"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list"]
-231
View File
@@ -1,231 +0,0 @@
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())
+43
View File
@@ -0,0 +1,43 @@
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()
+355
View File
@@ -0,0 +1,355 @@
import asyncio
import logging
import struct
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from typing import Dict, List, Optional
from .utils import *
from .stats import stats
from .balancer import balancer
from .config import proxy_config
from .raw_websocket import RawWebSocket
log = logging.getLogger('tg-mtproto-proxy')
_st_I_le = struct.Struct('<I')
ZERO_64 = b'\x00' * 64
DC_DEFAULT_IPS: Dict[int, str] = {
1: '149.154.175.50',
2: '149.154.167.51',
3: '149.154.175.100',
4: '149.154.167.91',
5: '149.154.171.5',
203: '91.105.192.100'
}
class CryptoCtx:
__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 = []
while self._cipher_buf:
packet_len = self._next_packet_len()
if packet_len is None:
break
if packet_len <= 0:
parts.append(bytes(self._cipher_buf))
self._cipher_buf.clear()
self._plain_buf.clear()
self._disabled = True
break
parts.append(bytes(self._cipher_buf[:packet_len]))
del self._cipher_buf[:packet_len]
del self._plain_buf[:packet_len]
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) -> Optional[int]:
if not self._plain_buf:
return None
if self._proto == PROTO_ABRIDGED_INT:
return self._next_abridged_len()
if self._proto in (PROTO_INTERMEDIATE_INT,
PROTO_PADDED_INTERMEDIATE_INT):
return self._next_intermediate_len()
return 0
def _next_abridged_len(self) -> Optional[int]:
first = self._plain_buf[0]
if first in (0x7F, 0xFF):
if len(self._plain_buf) < 4:
return None
payload_len = int.from_bytes(self._plain_buf[1: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 len(self._plain_buf) < packet_len:
return None
return packet_len
def _next_intermediate_len(self) -> Optional[int]:
if len(self._plain_buf) < 4:
return None
payload_len = _st_I_le.unpack_from(self._plain_buf, 0)[0] & 0x7FFFFFFF
if payload_len <= 0:
return 0
packet_len = 4 + payload_len
if len(self._plain_buf) < 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
cf_first = proxy_config.fallback_cfproxy_priority
methods: List[str] = ['tcp']
if use_cf:
methods.insert(0 if cf_first else 1, 'cf')
for method in methods:
if 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_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()
async def tcp_to_ws():
nonlocal up_bytes, up_packets
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, ConnectionError, OSError):
return
except Exception as e:
log.debug("[%s] tcp->ws ended: %s", label, e)
async def ws_to_tcp():
nonlocal down_bytes, down_packets
try:
while True:
data = await ws.recv()
if data is None:
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, ConnectionError, OSError):
return
except Exception as 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 (%d pkts) v%s (%d pkts) in %.1fs",
label, dc_tag,
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
+177
View File
@@ -0,0 +1,177 @@
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, urlopen
from .balancer import balancer
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'
]
_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
fallback_cfproxy_priority: bool = True
cfproxy_user_domain: str = ''
fake_tls_domain: str = ''
proxy_protocol: bool = False
proxy_config = ProxyConfig()
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 urlopen(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_domain:
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:
raise ValueError(
f"Invalid --dc-ip format {entry!r}, expected DC:IP")
dc_s, ip_s = entry.split(':', 1)
try:
dc_n = int(dc_s)
_socket.inet_aton(ip_s)
except (ValueError, OSError):
raise ValueError(f"Invalid --dc-ip {entry!r}")
dc_redirects[dc_n] = ip_s
return dc_redirects
-208
View File
@@ -1,208 +0,0 @@
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}")
+256
View File
@@ -0,0 +1,256 @@
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
+236
View File
@@ -0,0 +1,236 @@
import os
import ssl
import base64
import struct
import asyncio
import socket as _socket
from typing import List, Optional, Tuple
from .config import proxy_config
_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) -> 'RawWebSocket':
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
server_hostname=domain),
timeout=min(timeout, 10))
set_sock_opts(writer.transport, proxy_config.buffer_size)
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f'GET /apiws 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
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
@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
+35
View File
@@ -0,0 +1,35 @@
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_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
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")
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"bad={self.connections_bad} "
f"masked={self.connections_masked} "
f"err={self.ws_errors} "
f"pool={pool_s} "
f"up={human_bytes(self.bytes_up)} "
f"down={human_bytes(self.bytes_down)}")
stats = _Stats()
+278 -768
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import socket as _socket
from typing import Optional
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'
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
+4 -1
View File
@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
dynamic=["version"]
description = "Telegram Desktop WebSocket Bridge Proxy"
readme = "README.md"
readme = "docs/README.md"
requires-python = ">=3.8"
license = { name = "MIT", file = "LICENSE" }
@@ -71,3 +71,6 @@ packages = ["proxy", "ui", "utils"]
[tool.hatch.version]
path = "proxy/__init__.py"
[tool.ruff.lint]
ignore = ["F403", "F405"]
-264
View File
@@ -1,264 +0,0 @@
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()
-166
View File
@@ -1,166 +0,0 @@
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()
-142
View File
@@ -1,142 +0,0 @@
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()
-62
View File
@@ -1,62 +0,0 @@
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()
-57
View File
@@ -1,57 +0,0 @@
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()
+5 -2
View File
@@ -51,8 +51,11 @@ def ctk_theme_for_platform() -> CtkTheme:
return CtkTheme()
def apply_ctk_appearance(ctk: Any) -> None:
ctk.set_appearance_mode("auto")
_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"))
ctk.set_default_color_theme("blue")
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
+301 -10
View File
@@ -5,10 +5,11 @@ import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from proxy import __version__, get_link_host, parse_dc_ip_list
from proxy.balancer import balancer
from utils.update_check import RELEASES_PAGE_URL, get_status
from ui.ctk_theme import (
FIRST_RUN_FRAME_PAD,
CtkTheme,
@@ -27,8 +28,9 @@ _TIP_PORT = (
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram"
"Каждая строка: «номер:IP», например 4:149.154.167.220. "
"Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n"
"Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220"
)
_TIP_VERBOSE = (
"Если включено, в файл логов пишется больше подробностей — "
@@ -50,11 +52,149 @@ _TIP_AUTOSTART = (
"Если вы переместите программу в другую папку, автозапуск сбросится"
)
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_CFPROXY = (
"Использовать Cloudflare прокси для недоступных датацентров"
)
_TIP_CFPROXY_PRIORITY = (
"Пробовать CF-прокси раньше прямого TCP-подключения"
)
_TIP_CFPROXY_DOMAIN = (
"Ваш собственный домен, проксируемый через Cloudflare, для WS-подключения.\n"
"Если не указан — выбирается автоматически из поддерживаемых доменов"
)
_TIP_CFPROXY_USER_DOMAIN_CB = (
"Указать свой домен вместо автоматического выбора"
)
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
def _run_cfproxy_connectivity_test(domain: str) -> 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 in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
try:
with _socket.create_connection((host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET /apiws HTTP/1.1\r\n"
f"Host: {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 "нет ответа"
ssock.close()
raw.close()
except _socket.timeout:
results[dc] = "таймаут"
except OSError as exc:
msg = str(exc)
results[dc] = msg[:60] if len(msg) > 60 else msg
return results
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 _cfproxy_show_test_results(domain: str, results: dict) -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
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) == len(_CFPROXY_TEST_DCS):
title = "CF-прокси: всё работает"
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
elif not ok:
title = "CF-прокси: недоступен"
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
else:
title = "CF-прокси: частично работает"
msg = (
f"Домен: {domain}\n\n"
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n"
f"\u2717 Недоступны:\n"
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail)
)
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
def _cfproxy_show_auto_test_results(ok_domain, results: dict) -> None:
import tkinter as _tk
from tkinter import messagebox as _mb
if ok_domain is not None:
title = "CF-прокси: доступен"
ok = [dc for dc, v in results.items() if v is True]
msg = f"\u2713 CF-прокси работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны."
else:
title = "CF-прокси: недоступен"
msg = "\u2717 Ни один из автоматических CF-доменов не отвечает.\n"
msg += "Возможно, блокировка или проблемы с сетью."
root = _tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
_mb.showinfo(title, msg, parent=root)
root.destroy()
_INNER_W = 396
_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"]
_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"}
_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"}
_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"}
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict(
@@ -155,6 +295,10 @@ class TrayConfigFormWidgets:
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
cfproxy_var: Optional[Any] = None
cfproxy_priority_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
appearance_var: Optional[Any] = None
def install_tray_config_form(
@@ -170,7 +314,7 @@ def install_tray_config_form(
header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel(
header, text="Настройки прокси",
header, text="Настройки",
font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, anchor="w",
).pack(side="left")
@@ -178,8 +322,47 @@ 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))
appearance_var = ctk.StringVar(
value=_APPEARANCE_FROM_CFG.get(cfg.get("appearance", "auto"), "Авто")
)
def _on_appearance_change(choice: str) -> None:
cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto")
ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val])
cfg["appearance"] = cfg_val
ctk.CTkComboBox(
header,
values=_APPEARANCE_OPTIONS,
variable=appearance_var,
width=102,
height=28,
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
fg_color=theme.field_bg,
border_color=theme.field_border,
button_color=theme.field_border,
button_hover_color=theme.text_secondary,
dropdown_fg_color=theme.field_bg,
dropdown_text_color=theme.text_primary,
dropdown_hover_color=theme.field_border,
corner_radius=8,
state="readonly",
command=_on_appearance_change,
).pack(side="right")
ctk.CTkButton(
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))
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
@@ -233,6 +416,102 @@ def install_tray_config_form(
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
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, "Включить CF-прокси", cfproxy_var)
cf_cb.pack(side="left", padx=(0, 16))
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
cfproxy_priority_var = ctk.BooleanVar(
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
)
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет", cfproxy_priority_var)
cf_prio_cb.pack(side="left")
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
_cf_test_btn = [None]
def _on_cf_test():
user_domain = cfproxy_user_domain_var.get().strip() if cf_custom_cb_var.get() else ""
btn = _cf_test_btn[0]
if btn:
btn.configure(text="...", state="disabled")
import threading as _threading
if user_domain:
def _worker():
try:
res = _run_cfproxy_connectivity_test(user_domain)
if btn:
btn.after(0, lambda: _cfproxy_show_test_results(user_domain, res))
except Exception as exc:
log.error("CF proxy test failed: %s", exc)
finally:
if btn:
btn.after(0, lambda: btn.configure(text="Тест", 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: _cfproxy_show_auto_test_results(ok_domain, res))
except Exception as exc:
log.error("CF proxy auto-test failed: %s", exc)
finally:
if btn:
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
_threading.Thread(target=_worker_auto, daemon=True).start()
_cf_test_widget = ctk.CTkButton(
cf_row, text="Тест", 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_domain = cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", ""))
cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domain))
cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var)
cf_custom_cb.pack(side="left", padx=(0, 10))
attach_ctk_tooltip(cf_custom_cb, _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=saved_user_domain)
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, _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()
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
@@ -321,6 +600,10 @@ def install_tray_config_form(
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_priority_var=cfproxy_priority_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
appearance_var=appearance_var,
)
@@ -363,12 +646,12 @@ def validate_config_form(
return "Порт должен быть числом 1-65535"
lines = [
l.strip()
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if l.strip()
line.strip()
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
if line.strip()
]
try:
tg_ws_proxy.parse_dc_ip_list(lines)
parse_dc_ip_list(lines)
except ValueError as e:
return str(e)
@@ -397,6 +680,14 @@ 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_priority_var is not None:
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
if widgets.cfproxy_user_domain_var is not None:
new_cfg["cfproxy_user_domain"] = widgets.cfproxy_user_domain_var.get().strip()
if widgets.appearance_var is not None:
new_cfg["appearance"] = _APPEARANCE_TO_CFG.get(widgets.appearance_var.get(), "auto")
return new_cfg
@@ -445,7 +736,7 @@ def populate_first_run_window(
secret: str,
on_done: Callable[[bool], None],
) -> None:
link_host = tg_ws_proxy.get_link_host(host)
link_host = 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)
+3
View File
@@ -17,6 +17,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
"cfproxy": True,
"cfproxy_priority": True,
"cfproxy_user_domain": "",
}
+34 -25
View File
@@ -14,8 +14,8 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config
from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config
log = logging.getLogger("tg-ws-tray")
@@ -51,7 +51,7 @@ def ensure_dirs() -> None:
_lock_file_path: Optional[Path] = None
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
def _same_process(meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
@@ -60,23 +60,20 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> 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(script_hint: str = "") -> bool:
def acquire_lock() -> bool:
global _lock_file_path
ensure_dirs()
for f in list(APP_DIR.glob("*.lock")):
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
try:
f.unlink(missing_ok=True)
except OSError:
pass
continue
meta: dict = {}
try:
@@ -85,12 +82,17 @@ def acquire_lock(script_hint: str = "") -> bool:
meta = json.loads(raw)
except Exception:
pass
is_running = False
try:
if _same_process(meta, psutil.Process(pid), script_hint):
return False
is_running = _same_process(meta, psutil.Process(pid))
except Exception:
pass
f.unlink(missing_ok=True)
if is_running:
return False
try:
f.unlink(missing_ok=True)
except OSError:
pass
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
@@ -100,7 +102,10 @@ def acquire_lock(script_hint: str = "") -> bool:
encoding="utf-8",
)
except Exception:
lock_file.touch()
try:
lock_file.touch()
except Exception:
pass
_lock_file_path = lock_file
return True
@@ -127,7 +132,7 @@ def load_config() -> dict:
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
log.warning("Failed to load config: %s", repr(exc))
return dict(DEFAULT_CONFIG)
@@ -148,6 +153,7 @@ 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 = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
@@ -234,9 +240,9 @@ def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
loop.run_until_complete(_run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
log.error("Proxy thread crashed: %s", repr(exc))
if "Address already in use" in str(exc) or "10048" in str(exc):
on_port_busy(
"Не удалось запустить прокси:\n"
@@ -252,18 +258,21 @@ def _run_proxy_thread(on_port_busy: 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 = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
dc_redirects = parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
return False
pc = tg_ws_proxy.proxy_config
pc = 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.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
pc.cfproxy_user_domain = cfg.get("cfproxy_user_domain", DEFAULT_CONFIG["cfproxy_user_domain"])
return True
@@ -277,7 +286,7 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
on_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
pc = 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"
@@ -307,7 +316,7 @@ 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 = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
@@ -382,7 +391,7 @@ def maybe_notify_update(
):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
log.warning("Update check failed: %s", repr(exc))
threading.Thread(target=_work, daemon=True, name="update-check").start()
@@ -393,7 +402,7 @@ _ctk_root: Any = None
_ctk_root_ready = threading.Event()
def ensure_ctk_thread(ctk: Any) -> bool:
def ensure_ctk_thread(ctk: Any, mode: str = "auto") -> bool:
global _ctk_root
if ctk is None:
return False
@@ -405,7 +414,7 @@ def ensure_ctk_thread(ctk: Any) -> bool:
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
apply_ctk_appearance(ctk, mode)
_ctk_root = ctk.CTk()
_ctk_root.withdraw()
_ctk_root_ready.set()
+53 -1
View File
@@ -30,6 +30,7 @@ _state: Dict[str, Any] = {
"latest": None,
"html_url": None,
"error": None,
"assets": [],
}
@@ -72,7 +73,7 @@ def _parse_version_tuple(s: str) -> tuple:
return (0,)
parts = []
for seg in s.split("."):
digits = "".join(c for c in seg if c.isdigit())
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
if digits:
try:
parts.append(int(digits))
@@ -162,6 +163,7 @@ 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"] = (
@@ -181,6 +183,7 @@ 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)
@@ -200,6 +203,13 @@ 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:
@@ -221,3 +231,45 @@ def run_check(current_version: str) -> None:
def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках)."""
return dict(_state)
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
assets = _state.get("assets") or []
if not assets:
return None
# Try SHA256 match against release asset digests
try:
import hashlib
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 assets:
d = (a.get("digest") or "").lower()
if d.startswith("sha256:") and d[7:] == exe_sha:
return a["url"], a["name"]
except Exception:
pass
# Fallback
import struct
is_64 = struct.calcsize("P") * 8 == 64
try:
is_modern = sys.getwindowsversion().major >= 10
except Exception:
is_modern = True
if is_modern:
name = "TgWsProxy_windows.exe"
elif is_64:
name = "TgWsProxy_windows_7_64bit.exe"
else:
name = "TgWsProxy_windows_7_32bit.exe"
for a in assets:
if a.get("name") == name:
return a["url"], a["name"]
return None
+35
View File
@@ -0,0 +1,35 @@
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
+312 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import ctypes
import os
import subprocess
import sys
import threading
import time
@@ -30,13 +31,17 @@ try:
except ImportError:
Image = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import get_link_host
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,
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import (
@@ -52,6 +57,39 @@ from ui.ctk_theme import (
_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")
@@ -64,7 +102,9 @@ _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: str = "TG WS Proxy — Ошибка") -> None:
@@ -79,6 +119,227 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
def update_ctk_form(
text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None,
release_url: Optional[str] = None,
) -> str:
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="Обновить", 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="Страница", 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="Закрыть", 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:
import tempfile
import urllib.request
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"Ошибка: {msg}")
else:
_show_error(msg)
_step("Скачивание...")
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)
urllib.request.urlretrieve(download_url, str(tmp_path))
except Exception as exc:
_err(f"Не удалось скачать:\n{exc}")
if tmp_path:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Замена файла...")
try:
if old_exe.exists():
old_exe.unlink()
cur_exe.rename(old_exe)
except Exception as exc:
_err(f"Не удалось переименовать файл:\n{exc}")
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
try:
tmp_path.rename(cur_exe)
except Exception as exc:
_err(f"Не удалось переместить файл:\n{exc}")
try:
old_exe.rename(cur_exe)
except OSError:
pass
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return
_step("Перезапуск...")
_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)) if IS_FROZEN else None
choice = update_ctk_form(
f"Доступна новая версия: {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()
# autostart (registry)
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -175,7 +436,11 @@ 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():
os.startfile(str(LOG_FILE))
try:
os.startfile(str(LOG_FILE))
except Exception as exc:
log.error("Failed to open log file: %s", exc)
_show_error(f"Не удалось открыть файл логов:\n{exc}")
else:
_show_info("Файл логов ещё не создан.")
@@ -196,7 +461,7 @@ def _on_exit(icon=None, item=None) -> None:
# settings dialog
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk):
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
_show_error("customtkinter не установлен.")
return
@@ -224,16 +489,31 @@ def _edit_config_dialog() -> None:
autostart_value=cfg.get("autostart", False),
)
_original_appearance = ctk.get_appearance_mode()
def _finish() -> None:
root.destroy()
done.set()
def _cancel() -> None:
ctk.set_appearance_mode(_original_appearance)
_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("TG WS Proxy — Ошибка", merged, parent=root)
return
_ui_only_keys = {"appearance", "autostart", "check_updates"}
config_changed = any(merged.get(k) != cfg.get(k) for k in merged)
proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys)
if not config_changed:
_finish()
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
@@ -241,6 +521,10 @@ def _edit_config_dialog() -> None:
set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu()
if not proxy_changed:
_finish()
return
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
@@ -250,8 +534,8 @@ def _edit_config_dialog() -> None:
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
root.protocol("WM_DELETE_WINDOW", _cancel)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel)
ctk_run_dialog(_build)
@@ -262,7 +546,7 @@ def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
if not ensure_ctk_thread(ctk):
if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
FIRST_RUN_MARKER.touch()
return
@@ -297,7 +581,7 @@ def _build_menu():
return None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
link_host = get_link_host(host)
return pystray.Menu(
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
@@ -316,6 +600,10 @@ 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:
@@ -329,7 +617,7 @@ def run_tray() -> None:
return
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_maybe_do_update(_config, lambda: _exiting)
_show_first_run()
check_ipv6_warning(_show_info)
@@ -342,13 +630,27 @@ def run_tray() -> None:
def main() -> None:
if not acquire_lock("windows.py"):
if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock():
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
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__":