Compare commits

...

59 Commits

Author SHA1 Message Date
Dark-Avery c3794ed024 Merge a5641700ed into da4b521aba 2026-03-30 14:37:33 +00:00
Dark_Avery a5641700ed test(android): cover bridge startup without cryptography 2026-03-30 17:18:44 +03:00
Dark_Avery 509f50fcae fix(android): avoid cryptography dependency and preserve version on update errors 2026-03-30 17:03:26 +03:00
Dark_Avery e511ff597b fix(runtime): detect android bind errors as port conflicts 2026-03-30 16:38:09 +03:00
Dark_Avery 3552de7dbf fix(gitignore): ignore WSL android build symlink 2026-03-30 16:34:57 +03:00
Dark_Avery 7c8bc17db6 fix(android-build): run gradle from android project root 2026-03-30 16:34:18 +03:00
Dark_Avery 76b375bd03 fix(runtime): close ws pool tasks before loop shutdown 2026-03-30 16:32:34 +03:00
Dark_Avery 7ad377c12c fix(android-build): move WSL build outputs off Windows mounts 2026-03-30 16:22:38 +03:00
Dark_Avery 7e9acc47fc docs(readme): keep android-only additions on android_migration 2026-03-30 16:17:44 +03:00
Dark_Avery 0302a3b817 docs(readme): align android instructions with mtproto config 2026-03-30 16:15:41 +03:00
Dark_Avery 810991ea18 feat(android): switch config and tg intent to mtproto model 2026-03-30 16:14:54 +03:00
Dark_Avery 1599b1126c feat(runtime): adapt android_migration shell to upstream mtproto core 2026-03-30 16:14:42 +03:00
Dark_Avery 9e2c8c16ff merge: take upstream v1.4.0 core as android_migration base 2026-03-30 16:14:29 +03:00
gogamlg3 da4b521aba Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
Flowseal 07facfe18c Version bump 2026-03-29 20:00:31 +03:00
Qirashi 7a886dff26 Update ctk_theme.py (#480) 2026-03-29 19:56:43 +03:00
Flowseal 17e37f9ca0 host detect in first-run window 2026-03-29 19:55:39 +03:00
Flowseal 968827445f copy link, mtproto new first run notify 2026-03-29 17:57:55 +03:00
Flowseal be8d178e5c secret validation 2026-03-29 17:30:39 +03:00
Dark_Avery 79840806f2 Merge remote-tracking branch 'origin/android_migration' into android_migration 2026-03-29 16:50:15 +03:00
Dark_Avery 68a378bad9 feat(android): harden update checks, ci, and direct-only ui 2026-03-29 16:45:20 +03:00
Flowseal 46426c45b0 ctk refactoring 2026-03-29 15:21:56 +03:00
Flowseal c4a044542c fixes 2026-03-29 15:21:45 +03:00
Dark_Avery 336602e93a feat(android): harden update checks, ci, and direct-only ui 2026-03-28 22:36:24 +03:00
Dark_Avery 934eb345a2 feat(android): add update checks using shared python release checker 2026-03-28 22:36:20 +03:00
Dark_Avery 54b86cd9e2 feat(android): add separate legacy32 APK build for armeabi-v7a 2026-03-28 22:23:45 +03:00
Dark_Avery 4f65813785 fix(runtime): accept advanced logging and socket tuning settings 2026-03-28 22:22:30 +03:00
Dark_Avery e50ea25d91 Merge upstream/main into android_migration 2026-03-28 22:21:31 +03:00
Flowseal af74009b11 icon fix 2026-03-28 16:48:59 +03:00
Flowseal 6766db9812 mtproto recode 2026-03-28 15:45:08 +03:00
Flowseal 95f99be26b old socks removed 2026-03-28 13:07:51 +03:00
deexsed 0d11062c92 fix: остановка прокси из трея, пул WS и проверка обновлений (#443) 2026-03-27 10:54:33 +03:00
Dark-Avery e6cceac19f Merge branch 'main' into android_migration 2026-03-23 20:09:08 +03:00
Dark_Avery faea437556 feat(android): add advanced upstream tuning settings 2026-03-23 02:42:08 +03:00
Dark_Avery b5b6a8021e Merge upstream/main into android_migration 2026-03-23 02:25:58 +03:00
Dark_Avery 85b111d0f3 refactor(desktop): move windows linux and macos launchers to shared runtime 2026-03-19 20:34:52 +03:00
Dark_Avery 3d10eb9113 Merge upstream/main into android_migration 2026-03-19 20:26:56 +03:00
Dark-Avery 30f902e0fb docs: align README with upstream and add minimal Android sections 2026-03-17 04:04:33 +03:00
Dark-Avery 09fbc5d876 fix(android): restore notification payload compile after rollback 2026-03-17 01:12:56 +03:00
Dark-Avery ee4d41ab9c Merge pull request #3 from Dark-Avery/android_migration
add restart action, log viewer, service error state, tray icon
2026-03-17 00:59:36 +03:00
Dark-Avery cf758f6d68 fix(android): use dedicated notification icon instead of system download glyph 2026-03-17 00:58:21 +03:00
Dark-Avery 6cbec90360 feat(android): add restart action, log viewer, and persistent service error state 2026-03-17 00:40:56 +03:00
Dark-Avery bf21bbfa95 docs(android): update README for Android fork, signed APK releases, and setup flow 2026-03-17 00:05:16 +03:00
Dark-Avery a57f238d3c Merge pull request #2 from Dark-Avery/android_migration
Android migration
2026-03-16 23:49:42 +03:00
Dark-Avery c61e2e84ed feat(ci): build and publish signed Android release APK 2026-03-16 23:48:29 +03:00
Dark-Avery 61713703f8 build(android): add env-based release signing config 2026-03-16 23:30:08 +03:00
Dark-Avery da15296f66 feat(android): show proxy traffic stats in foreground notification 2026-03-16 23:15:12 +03:00
Dark-Avery 8d43fa25fa feat(android): add richer service notification 2026-03-16 22:58:43 +03:00
Dark-Avery db5a6cc696 feat(android): add Telegram proxy intent and background-limit status checks 2026-03-16 22:18:06 +03:00
Dark-Avery c5f8b40570 fix(android): normalize Chaquopy Java list inputs for proxy config 2026-03-16 21:39:04 +03:00
Dark-Avery 360ea20902 Merge pull request #1 from Dark-Avery/android_migration
Android migration
2026-03-16 21:21:43 +03:00
Dark-Avery fe55624e24 feat(ci): build and publish Android debug APK in GitHub releases 2026-03-16 21:19:50 +03:00
Dark-Avery ec6de3afb3 feat(android): embed python runtime and boot proxy service inside foreground service 2026-03-16 21:13:35 +03:00
Dark-Avery 47e5c6241d feat(android): scaffold kotlin app with settings screen and foreground service shell 2026-03-16 19:45:57 +03:00
Dark-Avery ec70188385 refactor(core): extract cross-platform proxy runtime from windows app 2026-03-16 17:22:51 +03:00
Dark-Avery ecc89d45d6 feat(crypto): add android-compatible pure-python AES-CTR backend 2026-03-16 17:14:23 +03:00
Dark-Avery 5e6fbdffda test(socks5): cover handshake, address parsing and connect failures 2026-03-16 16:46:23 +03:00
Dark-Avery 7dc9b04016 test(crypto): add MTProto init and splitter coverage 2026-03-16 16:43:22 +03:00
Dark-Avery 1a12548daf refactor(crypto): extract AES-CTR backend adapter and keep cryptography as desktop backend 2026-03-16 16:36:22 +03:00
52 changed files with 6044 additions and 2397 deletions
+104 -2
View File
@@ -16,6 +16,9 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-windows:
runs-on: windows-latest
@@ -303,7 +306,7 @@ jobs:
Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy
SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI.
MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
EOF
dpkg-deb --build --root-owner-group \
@@ -318,8 +321,99 @@ jobs:
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 }}
release:
needs: [build-windows, build-win7, build-macos, build-linux]
needs: [build-windows, build-win7, build-macos, build-linux, build-android]
runs-on: ubuntu-latest
if: ${{ github.event.inputs.make_release == 'true' }}
steps:
@@ -329,6 +423,12 @@ 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:
@@ -343,6 +443,8 @@ 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
draft: false
prerelease: false
env:
+12
View File
@@ -16,6 +16,18 @@ 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
+2 -2
View File
@@ -23,7 +23,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH=/opt/venv/bin:$PATH \
TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1080 \
TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
RUN apt-get update \
@@ -39,7 +39,7 @@ COPY README.md LICENSE ./
USER app
EXPOSE 1080/tcp
EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD []
+72 -14
View File
@@ -12,17 +12,17 @@
# TG WS Proxy
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
**Локальный 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 → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC
Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
```
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080`
1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
@@ -38,7 +38,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
**Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку
- **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов
@@ -69,8 +69,9 @@ makepkg -si
# При помощи AUR-helper
paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси:
sudo systemctl start tg-ws-proxy-cli@8888
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
@@ -82,11 +83,28 @@ chmod +x 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
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки:
Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash
pip install -e .
@@ -120,13 +138,33 @@ tg-ws-proxy-tray-linux
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` | `1080` | Порт SOCKS5-прокси |
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси |
| `--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) |
**Примеры:**
@@ -166,10 +204,30 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения****Прокси**
2. Добавить прокси:
- **Тип:** SOCKS5
- **Тип:** MTProto
- **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1443` (или переопределенный вами)
- **Secret:** из настроек или логов
## Настройка Telegram Android
### Автоматически
В приложении нажмите **Open in Telegram** после запуска foreground service.
### Вручную
1. Telegram → **Настройки****Данные и память****Настройки прокси**
2. Добавить прокси:
- **Тип:** MTProto
- **Сервер:** `127.0.0.1`
- **Порт:** `1080`
- **Логин/Пароль:** оставить пустыми
- **Порт:** `1443`
- **Secret:** из настроек приложения
Важно:
- сначала должен быть запущен foreground service
- если Telegram был уже открыт, иногда проще закрыть и открыть его заново после запуска прокси
## Конфигурация
@@ -182,7 +240,8 @@ Tray-приложение хранит данные в:
```json
{
"host": "127.0.0.1",
"port": 1080,
"port": 1443,
"secret": "...",
"dc_ip": [
"2:149.154.167.220",
"4:149.154.167.220"
@@ -209,7 +268,6 @@ Tray-приложение хранит данные в:
- Intel macOS 10.15+
- Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея)
## Лицензия
[MIT License](LICENSE)
+170
View File
@@ -0,0 +1,170 @@
import org.gradle.api.tasks.Sync
import org.gradle.api.GradleException
import java.io.File
plugins {
id("com.android.application")
id("com.chaquo.python")
id("org.jetbrains.kotlin.android")
}
fun loadProxyVersionName(): String {
val versionFile = rootProject.projectDir.resolve("../proxy/__init__.py")
val match = Regex("""__version__\s*=\s*"([^"]+)"""")
.find(versionFile.readText())
?: throw GradleException("Failed to parse proxy version from ${versionFile.absolutePath}")
return match.groupValues[1]
}
data class ReleaseSigningEnv(
val keystoreFile: File,
val storePassword: String,
val keyAlias: String,
val keyPassword: String,
)
fun requiredEnv(name: String): String {
return System.getenv(name)?.takeIf { it.isNotBlank() }
?: throw GradleException("Missing required environment variable: $name")
}
fun loadReleaseSigningEnv(releaseSigningRequested: Boolean): ReleaseSigningEnv? {
val keystorePath = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
val anySigningEnvProvided = listOf(
keystorePath,
System.getenv("ANDROID_KEYSTORE_PASSWORD"),
System.getenv("ANDROID_KEY_ALIAS"),
System.getenv("ANDROID_KEY_PASSWORD"),
).any { !it.isNullOrBlank() }
if (!releaseSigningRequested && !anySigningEnvProvided) {
return null
}
val keystoreFile = File(requiredEnv("ANDROID_KEYSTORE_FILE"))
if (!keystoreFile.isFile) {
throw GradleException("ANDROID_KEYSTORE_FILE does not exist: ${keystoreFile.absolutePath}")
}
return ReleaseSigningEnv(
keystoreFile = keystoreFile,
storePassword = requiredEnv("ANDROID_KEYSTORE_PASSWORD"),
keyAlias = requiredEnv("ANDROID_KEY_ALIAS"),
keyPassword = requiredEnv("ANDROID_KEY_PASSWORD"),
)
}
val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python")
val stagePythonSources by tasks.registering(Sync::class) {
from(rootProject.projectDir.resolve("../proxy")) {
into("proxy")
}
from(rootProject.projectDir.resolve("../utils")) {
into("utils")
}
into(stagedPythonSourcesDir)
}
val releaseSigningRequested = gradle.startParameter.taskNames.any {
it.contains("release", ignoreCase = true)
}
val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested)
val appVersionName = loadProxyVersionName()
android {
namespace = "org.flowseal.tgwsproxy"
compileSdk = 34
defaultConfig {
applicationId = "org.flowseal.tgwsproxy"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = appVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
flavorDimensions += "runtime"
productFlavors {
create("standard") {
dimension = "runtime"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
create("legacy32") {
dimension = "runtime"
versionNameSuffix = "-legacy32"
ndk {
abiFilters += listOf("armeabi-v7a")
}
}
}
signingConfigs {
if (releaseSigningEnv != null) {
create("release") {
storeFile = releaseSigningEnv.keystoreFile
storePassword = releaseSigningEnv.storePassword
keyAlias = releaseSigningEnv.keyAlias
keyPassword = releaseSigningEnv.keyPassword
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (releaseSigningEnv != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
chaquopy {
productFlavors {
getByName("standard") {
version = "3.12"
}
getByName("legacy32") {
version = "3.11"
}
}
sourceSets {
getByName("main") {
srcDir("src/main/python")
srcDir(stagePythonSources)
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
+1
View File
@@ -0,0 +1 @@
# Intentionally empty for the initial Android shell.
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_proxy_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_proxy_app"
android:supportsRtl="true"
android:theme="@style/Theme.TgWsProxy">
<activity
android:name=".LogViewerActivity"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ProxyForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,62 @@
package org.flowseal.tgwsproxy
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
data class AndroidSystemStatus(
val ignoringBatteryOptimizations: Boolean,
val backgroundRestricted: Boolean,
) {
val canKeepRunningReliably: Boolean
get() = ignoringBatteryOptimizations && !backgroundRestricted
companion object {
fun read(context: Context): AndroidSystemStatus {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val ignoringBatteryOptimizations = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
powerManager.isIgnoringBatteryOptimizations(context.packageName)
} else {
true
}
val backgroundRestricted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activityManager.isBackgroundRestricted
} else {
false
}
return AndroidSystemStatus(
ignoringBatteryOptimizations = ignoringBatteryOptimizations,
backgroundRestricted = backgroundRestricted,
)
}
fun openBatteryOptimizationSettings(context: Context) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
}
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
fun openAppSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
@@ -0,0 +1,53 @@
package org.flowseal.tgwsproxy
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.flowseal.tgwsproxy.databinding.ActivityLogViewerBinding
import java.io.File
class LogViewerActivity : AppCompatActivity() {
private lateinit var binding: ActivityLogViewerBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLogViewerBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.refreshLogsButton.setOnClickListener { renderLog() }
binding.closeLogsButton.setOnClickListener { finish() }
renderLog()
}
override fun onResume() {
super.onResume()
renderLog()
}
private fun renderLog() {
val logFile = File(filesDir, "tg-ws-proxy/proxy.log")
binding.logPathValue.text = logFile.absolutePath
binding.logContentValue.text = readLogTail(logFile)
}
private fun readLogTail(logFile: File, maxChars: Int = 40000): String {
if (!logFile.isFile) {
return getString(R.string.logs_empty)
}
val text = runCatching {
logFile.readText(Charsets.UTF_8)
}.getOrElse { error ->
return getString(R.string.logs_read_failed, error.message ?: error.javaClass.simpleName)
}
if (text.isBlank()) {
return getString(R.string.logs_empty)
}
if (text.length <= maxChars) {
return text
}
return getString(R.string.logs_truncated_prefix) + "\n\n" + text.takeLast(maxChars)
}
}
@@ -0,0 +1,330 @@
package org.flowseal.tgwsproxy
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var settingsStore: ProxySettingsStore
private var currentUpdateStatus: ProxyUpdateStatus? = null
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (!granted) {
Toast.makeText(
this,
"Без уведомлений Android может скрыть foreground service.",
Toast.LENGTH_LONG,
).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
settingsStore = ProxySettingsStore(this)
setContentView(binding.root)
binding.startButton.setOnClickListener { onStartClicked() }
binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) }
binding.restartButton.setOnClickListener { onRestartClicked() }
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) }
binding.openLogsButton.setOnClickListener { onOpenLogsClicked() }
binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() }
binding.openReleasePageButton.setOnClickListener { onOpenReleasePageClicked() }
binding.checkUpdatesSwitch.setOnCheckedChangeListener { _, _ ->
renderUpdateStatus(currentUpdateStatus, binding.checkUpdatesSwitch.isChecked)
}
binding.disableBatteryOptimizationButton.setOnClickListener {
AndroidSystemStatus.openBatteryOptimizationSettings(this)
}
binding.openAppSettingsButton.setOnClickListener {
AndroidSystemStatus.openAppSettings(this)
}
val config = settingsStore.load()
renderConfig(config)
if (config.checkUpdates) {
refreshUpdateStatus(checkNow = true)
} else {
currentUpdateStatus = null
renderUpdateStatus(null, false)
}
requestNotificationPermissionIfNeeded()
observeServiceState()
renderSystemStatus()
}
override fun onResume() {
super.onResume()
renderSystemStatus()
}
private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? {
val validation = collectConfigFromForm().validate()
val config = validation.normalized
if (config == null) {
binding.errorText.text = validation.errorMessage
binding.errorText.isVisible = true
return null
}
binding.errorText.isVisible = false
settingsStore.save(config)
if (showMessage) {
Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show()
}
if (config.checkUpdates) {
refreshUpdateStatus(checkNow = true)
} else {
currentUpdateStatus = null
renderUpdateStatus(null, false)
}
return config
}
private fun onStartClicked() {
onSaveClicked(showMessage = false) ?: return
ProxyForegroundService.start(this)
Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show()
}
private fun onRestartClicked() {
onSaveClicked(showMessage = false) ?: return
ProxyForegroundService.restart(this)
Snackbar.make(binding.root, R.string.service_restart_requested, Snackbar.LENGTH_SHORT).show()
}
private fun onOpenLogsClicked() {
startActivity(Intent(this, LogViewerActivity::class.java))
}
private fun onOpenTelegramClicked() {
val config = onSaveClicked(showMessage = false) ?: return
if (!TelegramProxyIntent.open(this, config)) {
Snackbar.make(binding.root, R.string.telegram_not_found, Snackbar.LENGTH_LONG).show()
}
}
private fun renderConfig(config: ProxyConfig) {
binding.hostInput.setText(config.host)
binding.portInput.setText(config.portText)
binding.secretInput.setText(config.secretText)
binding.dcIpInput.setText(config.dcIpText)
binding.logMaxMbInput.setText(config.logMaxMbText)
binding.bufferKbInput.setText(config.bufferKbText)
binding.poolSizeInput.setText(config.poolSizeText)
binding.checkUpdatesSwitch.isChecked = config.checkUpdates
binding.verboseSwitch.isChecked = config.verbose
renderUpdateStatus(currentUpdateStatus, config.checkUpdates)
}
private fun collectConfigFromForm(): ProxyConfig {
return ProxyConfig(
host = binding.hostInput.text?.toString().orEmpty(),
portText = binding.portInput.text?.toString().orEmpty(),
secretText = binding.secretInput.text?.toString().orEmpty(),
dcIpText = binding.dcIpInput.text?.toString().orEmpty(),
logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(),
bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(),
poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(),
checkUpdates = binding.checkUpdatesSwitch.isChecked,
verbose = binding.verboseSwitch.isChecked,
)
}
private fun onOpenReleasePageClicked() {
val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
val opened = runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}.isSuccess
if (!opened) {
Snackbar.make(binding.root, R.string.release_page_open_failed, Snackbar.LENGTH_LONG).show()
}
}
private fun refreshUpdateStatus(checkNow: Boolean) {
lifecycleScope.launch {
val status = runCatching {
withContext(Dispatchers.IO) {
PythonProxyBridge.getUpdateStatus(this@MainActivity, checkNow)
}
}.getOrElse { exc ->
ProxyUpdateStatus(
currentVersion = currentAppVersionName(),
error = exc.message ?: exc.javaClass.simpleName,
)
}
currentUpdateStatus = status
renderUpdateStatus(status, binding.checkUpdatesSwitch.isChecked)
}
}
private fun renderUpdateStatus(status: ProxyUpdateStatus?, checkUpdatesEnabled: Boolean) {
val currentVersion = status?.currentVersion?.takeIf { it.isNotBlank() } ?: currentAppVersionName()
binding.currentVersionValue.text = getString(
R.string.updates_current_version_format,
currentVersion,
)
binding.updateStatusValue.text = when {
!checkUpdatesEnabled -> {
getString(R.string.updates_status_disabled)
}
status == null -> {
getString(R.string.updates_status_initial)
}
!status.error.isNullOrBlank() -> {
getString(R.string.updates_status_error, status.error)
}
!status.checked -> {
getString(R.string.updates_status_idle)
}
status.hasUpdate && !status.latestVersion.isNullOrBlank() -> {
getString(
R.string.updates_status_available,
status.latestVersion,
status.currentVersion,
)
}
status.aheadOfRelease -> {
getString(R.string.updates_status_newer, status.currentVersion)
}
else -> {
getString(R.string.updates_status_latest, status.currentVersion)
}
}
}
private fun currentAppVersionName(): String {
return runCatching {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull().orEmpty().ifBlank { "unknown" }
}
private fun observeServiceState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
ProxyServiceState.isStarting,
ProxyServiceState.isRunning,
) { isStarting, isRunning ->
isStarting to isRunning
}.collect { (isStarting, isRunning) ->
binding.statusValue.text = getString(
when {
isStarting -> R.string.status_starting
isRunning -> R.string.status_running
else -> R.string.status_stopped
},
)
binding.startButton.isEnabled = !isStarting && !isRunning
binding.stopButton.isEnabled = isStarting || isRunning
binding.restartButton.isEnabled = !isStarting
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
ProxyServiceState.activeConfig,
ProxyServiceState.isStarting,
) { config, isStarting ->
config to isStarting
}.collect { (config, isStarting) ->
binding.serviceHint.text = if (config == null) {
getString(R.string.service_hint_idle)
} else if (isStarting) {
getString(
R.string.service_hint_starting,
config.host,
config.port,
)
} else {
getString(
R.string.service_hint_running,
config.host,
config.port,
)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.lastError.collect { error ->
if (error.isNullOrBlank()) {
binding.lastErrorCard.isVisible = false
} else {
binding.lastErrorValue.text = error
binding.lastErrorCard.isVisible = true
}
}
}
}
}
private fun renderSystemStatus() {
val status = AndroidSystemStatus.read(this)
binding.systemStatusValue.text = getString(
if (status.canKeepRunningReliably) {
R.string.system_status_ready
} else {
R.string.system_status_attention
},
)
val lines = mutableListOf<String>()
lines += if (status.ignoringBatteryOptimizations) {
getString(R.string.system_check_battery_ignored)
} else {
getString(R.string.system_check_battery_active)
}
lines += if (status.backgroundRestricted) {
getString(R.string.system_check_background_restricted)
} else {
getString(R.string.system_check_background_ok)
}
lines += getString(R.string.system_check_oem_note)
binding.systemStatusHint.text = lines.joinToString("\n")
binding.disableBatteryOptimizationButton.isVisible = !status.ignoringBatteryOptimizations
binding.openAppSettingsButton.isVisible = status.backgroundRestricted || !status.ignoringBatteryOptimizations
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return
}
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -0,0 +1,156 @@
package org.flowseal.tgwsproxy
import java.security.SecureRandom
data class ProxyConfig(
val host: String = DEFAULT_HOST,
val portText: String = DEFAULT_PORT.toString(),
val secretText: String = DEFAULT_SECRET,
val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"),
val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB),
val bufferKbText: String = DEFAULT_BUFFER_KB.toString(),
val poolSizeText: String = DEFAULT_POOL_SIZE.toString(),
val checkUpdates: Boolean = false,
val verbose: Boolean = false,
) {
fun validate(): ValidationResult {
val hostValue = host.trim()
if (!isIpv4Address(hostValue)) {
return ValidationResult(errorMessage = "IP-адрес прокси указан некорректно.")
}
val portValue = portText.trim().toIntOrNull()
?: return ValidationResult(errorMessage = "Порт должен быть числом.")
if (portValue !in 1..65535) {
return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.")
}
val secretValue = secretText.trim().lowercase()
if (secretValue.length != 32 || !secretValue.all { it in "0123456789abcdef" }) {
return ValidationResult(
errorMessage = "MTProto secret должен содержать ровно 32 hex-символа."
)
}
val lines = dcIpText
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
if (lines.isEmpty()) {
return ValidationResult(errorMessage = "Добавьте хотя бы один DC:IP маппинг.")
}
for (line in lines) {
val parts = line.split(":", limit = 2)
val dcValue = parts.firstOrNull()?.toIntOrNull()
val ipValue = parts.getOrNull(1)?.trim().orEmpty()
if (parts.size != 2 || dcValue == null || !isIpv4Address(ipValue)) {
return ValidationResult(errorMessage = "Строка \"$line\" должна быть в формате DC:IP.")
}
}
val logMaxMbValue = logMaxMbText.trim().toDoubleOrNull()
?: return ValidationResult(
errorMessage = "Размер лог-файла должен быть числом."
)
if (logMaxMbValue <= 0.0) {
return ValidationResult(
errorMessage = "Размер лог-файла должен быть больше нуля."
)
}
val bufferKbValue = bufferKbText.trim().toIntOrNull()
?: return ValidationResult(
errorMessage = "Буфер сокета должен быть целым числом."
)
if (bufferKbValue < 4) {
return ValidationResult(
errorMessage = "Буфер сокета должен быть не меньше 4 KB."
)
}
val poolSizeValue = poolSizeText.trim().toIntOrNull()
?: return ValidationResult(
errorMessage = "Размер WS pool должен быть целым числом."
)
if (poolSizeValue < 0) {
return ValidationResult(
errorMessage = "Размер WS pool не может быть отрицательным."
)
}
return ValidationResult(
normalized = NormalizedProxyConfig(
host = hostValue,
port = portValue,
secret = secretValue,
dcIpList = lines,
logMaxMb = logMaxMbValue,
bufferKb = bufferKbValue,
poolSize = poolSizeValue,
checkUpdates = checkUpdates,
verbose = verbose,
)
)
}
companion object {
const val DEFAULT_HOST = "127.0.0.1"
const val DEFAULT_PORT = 1443
const val DEFAULT_LOG_MAX_MB = 5.0
const val DEFAULT_BUFFER_KB = 256
const val DEFAULT_POOL_SIZE = 4
val DEFAULT_SECRET = generateSecret()
val DEFAULT_DC_IP_LINES = listOf(
"2:149.154.167.220",
"4:149.154.167.220",
)
fun formatDecimal(value: Double): String {
return if (value % 1.0 == 0.0) {
value.toInt().toString()
} else {
value.toString()
}
}
private fun generateSecret(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return bytes.joinToString(separator = "") { "%02x".format(it) }
}
private fun isIpv4Address(value: String): Boolean {
val octets = value.split(".")
if (octets.size != 4) {
return false
}
return octets.all { octet ->
octet.isNotEmpty() &&
octet.length <= 3 &&
octet.all(Char::isDigit) &&
octet.toIntOrNull() in 0..255
}
}
}
}
data class ValidationResult(
val normalized: NormalizedProxyConfig? = null,
val errorMessage: String? = null,
)
data class NormalizedProxyConfig(
val host: String,
val port: Int,
val secret: String,
val dcIpList: List<String>,
val logMaxMb: Double,
val bufferKb: Int,
val poolSize: Int,
val checkUpdates: Boolean,
val verbose: Boolean,
)
@@ -0,0 +1,395 @@
package org.flowseal.tgwsproxy
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.TaskStackBuilder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Locale
class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var trafficJob: Job? = null
private var lastTrafficSample: TrafficSample? = null
override fun onCreate() {
super.onCreate()
settingsStore = ProxySettingsStore(this)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return when (intent?.action) {
ACTION_STOP -> {
ProxyServiceState.clearError()
serviceScope.launch {
stopProxyRuntime(removeNotification = true, stopService = true)
}
START_NOT_STICKY
}
ACTION_RESTART -> {
val config = loadValidatedConfig() ?: return START_NOT_STICKY
ProxyServiceState.clearError()
beginProxyStart(config)
serviceScope.launch {
stopRuntimeOnly()
startProxyRuntime(config)
}
START_STICKY
}
else -> {
val config = loadValidatedConfig() ?: return START_NOT_STICKY
beginProxyStart(config)
serviceScope.launch {
startProxyRuntime(config)
}
START_STICKY
}
}
}
override fun onDestroy() {
stopTrafficUpdates()
serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(payload: NotificationPayload): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(payload.statusText)
.setSubText(payload.endpointText)
.setStyle(
NotificationCompat.BigTextStyle().bigText(payload.detailsText),
)
.setSmallIcon(R.drawable.ic_proxy_notification)
.setContentIntent(createOpenAppPendingIntent())
.addAction(
0,
getString(R.string.notification_action_stop),
createStopPendingIntent(),
)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
private suspend fun startProxyRuntime(config: NormalizedProxyConfig) {
val result = runCatching {
PythonProxyBridge.start(this, config)
}
result.onSuccess {
ProxyServiceState.markStarted(config)
lastTrafficSample = null
updateNotification(
buildNotificationPayload(
config = config,
trafficState = TrafficState(running = true),
statusText = getString(
R.string.notification_running,
config.host,
config.port,
),
),
)
startTrafficUpdates(config)
}.onFailure { error ->
ProxyServiceState.markFailed(
error.message ?: getString(R.string.proxy_start_failed_generic),
)
stopTrafficUpdates()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun loadValidatedConfig(): NormalizedProxyConfig? {
val config = settingsStore.load().validate().normalized
if (config == null) {
ProxyServiceState.markFailed(getString(R.string.saved_config_invalid))
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return config
}
private fun beginProxyStart(config: NormalizedProxyConfig) {
ProxyServiceState.markStarting(config)
startForeground(
NOTIFICATION_ID,
buildNotification(
buildNotificationPayload(
config = config,
trafficState = TrafficState(),
statusText = getString(
R.string.notification_starting,
config.host,
config.port,
),
),
),
)
}
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
stopRuntimeOnly()
ProxyServiceState.markStopped()
if (removeNotification) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
if (stopService) {
stopSelf()
}
}
private fun stopRuntimeOnly() {
stopTrafficUpdates()
runCatching { PythonProxyBridge.stop(this) }
}
private fun updateNotification(payload: NotificationPayload) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(payload))
}
private fun buildNotificationPayload(
config: NormalizedProxyConfig,
trafficState: TrafficState,
statusText: String,
): NotificationPayload {
val endpointText = getString(R.string.notification_endpoint, config.host, config.port)
val detailsText = getString(
R.string.notification_details,
config.dcIpList.size,
formatRate(trafficState.upBytesPerSecond),
formatRate(trafficState.downBytesPerSecond),
formatBytes(trafficState.totalBytesUp),
formatBytes(trafficState.totalBytesDown),
)
return NotificationPayload(
statusText = statusText,
endpointText = endpointText,
detailsText = detailsText,
)
}
private fun startTrafficUpdates(config: NormalizedProxyConfig) {
stopTrafficUpdates()
trafficJob = serviceScope.launch {
while (isActive && ProxyServiceState.isRunning.value) {
val trafficState = readTrafficState()
if (!trafficState.running) {
ProxyServiceState.markFailed(
trafficState.lastError ?: getString(R.string.proxy_runtime_stopped_unexpectedly),
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
break
}
updateNotification(
buildNotificationPayload(
config = config,
trafficState = trafficState,
statusText = getString(
R.string.notification_running,
config.host,
config.port,
),
),
)
delay(1000)
}
}
}
private fun stopTrafficUpdates() {
trafficJob?.cancel()
trafficJob = null
lastTrafficSample = null
}
private fun readTrafficState(): TrafficState {
val nowMillis = System.currentTimeMillis()
val current = PythonProxyBridge.getTrafficStats(this)
val previous = lastTrafficSample
lastTrafficSample = TrafficSample(
bytesUp = current.bytesUp,
bytesDown = current.bytesDown,
timestampMillis = nowMillis,
)
if (!current.running || previous == null) {
return TrafficState(
upBytesPerSecond = 0L,
downBytesPerSecond = 0L,
totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown,
running = current.running,
lastError = current.lastError,
)
}
val elapsedMillis = (nowMillis - previous.timestampMillis).coerceAtLeast(1L)
val upDelta = (current.bytesUp - previous.bytesUp).coerceAtLeast(0L)
val downDelta = (current.bytesDown - previous.bytesDown).coerceAtLeast(0L)
return TrafficState(
upBytesPerSecond = (upDelta * 1000L) / elapsedMillis,
downBytesPerSecond = (downDelta * 1000L) / elapsedMillis,
totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown,
running = current.running,
lastError = current.lastError,
)
}
private fun formatRate(bytesPerSecond: Long): String = formatBytes(bytesPerSecond)
private fun formatBytes(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var value = bytes.toDouble().coerceAtLeast(0.0)
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
return if (unitIndex == 0) {
String.format(Locale.US, "%.0f %s", value, units[unitIndex])
} else {
String.format(Locale.US, "%.1f %s", value, units[unitIndex])
}
}
private fun createOpenAppPendingIntent(): PendingIntent {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
}
?: Intent(this, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
}
return TaskStackBuilder.create(this)
.addNextIntentWithParentStack(launchIntent)
.getPendingIntent(
1,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
?: PendingIntent.getActivity(
this,
1,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun createStopPendingIntent(): PendingIntent {
val intent = Intent(this, ProxyForegroundService::class.java).apply {
action = ACTION_STOP
}
return PendingIntent.getService(
this,
2,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
private const val CHANNEL_ID = "proxy_service"
private const val NOTIFICATION_ID = 1001
private const val ACTION_START = "org.flowseal.tgwsproxy.action.START"
private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP"
private const val ACTION_RESTART = "org.flowseal.tgwsproxy.action.RESTART"
fun start(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply {
action = ACTION_START
}
androidx.core.content.ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
fun restart(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply {
action = ACTION_RESTART
}
androidx.core.content.ContextCompat.startForegroundService(context, intent)
}
}
}
private data class NotificationPayload(
val statusText: String,
val endpointText: String,
val detailsText: String,
)
private data class TrafficSample(
val bytesUp: Long,
val bytesDown: Long,
val timestampMillis: Long,
)
private data class TrafficState(
val upBytesPerSecond: Long = 0L,
val downBytesPerSecond: Long = 0L,
val totalBytesUp: Long = 0L,
val totalBytesDown: Long = 0L,
val running: Boolean = false,
val lastError: String? = null,
)
@@ -0,0 +1,49 @@
package org.flowseal.tgwsproxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ProxyServiceState {
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning
private val _isStarting = MutableStateFlow(false)
val isStarting: StateFlow<Boolean> = _isStarting
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError
fun markStarting(config: NormalizedProxyConfig) {
_activeConfig.value = config
_isStarting.value = true
_isRunning.value = false
_lastError.value = null
}
fun markStarted(config: NormalizedProxyConfig) {
_activeConfig.value = config
_isStarting.value = false
_isRunning.value = true
_lastError.value = null
}
fun markFailed(message: String) {
_activeConfig.value = null
_isStarting.value = false
_isRunning.value = false
_lastError.value = message
}
fun markStopped() {
_activeConfig.value = null
_isStarting.value = false
_isRunning.value = false
}
fun clearError() {
_lastError.value = null
}
}
@@ -0,0 +1,62 @@
package org.flowseal.tgwsproxy
import android.content.Context
class ProxySettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): ProxyConfig {
return ProxyConfig(
host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(),
portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(),
secretText = preferences.getString(KEY_SECRET, ProxyConfig.DEFAULT_SECRET).orEmpty(),
dcIpText = preferences.getString(
KEY_DC_IP_TEXT,
ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"),
).orEmpty(),
logMaxMbText = ProxyConfig.formatDecimal(
preferences.getFloat(
KEY_LOG_MAX_MB,
ProxyConfig.DEFAULT_LOG_MAX_MB.toFloat(),
).toDouble()
),
bufferKbText = preferences.getInt(
KEY_BUFFER_KB,
ProxyConfig.DEFAULT_BUFFER_KB,
).toString(),
poolSizeText = preferences.getInt(
KEY_POOL_SIZE,
ProxyConfig.DEFAULT_POOL_SIZE,
).toString(),
checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, false),
verbose = preferences.getBoolean(KEY_VERBOSE, false),
)
}
fun save(config: NormalizedProxyConfig) {
preferences.edit()
.putString(KEY_HOST, config.host)
.putInt(KEY_PORT, config.port)
.putString(KEY_SECRET, config.secret)
.putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n"))
.putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat())
.putInt(KEY_BUFFER_KB, config.bufferKb)
.putInt(KEY_POOL_SIZE, config.poolSize)
.putBoolean(KEY_CHECK_UPDATES, config.checkUpdates)
.putBoolean(KEY_VERBOSE, config.verbose)
.apply()
}
companion object {
private const val PREFS_NAME = "proxy_settings"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_SECRET = "secret"
private const val KEY_DC_IP_TEXT = "dc_ip_text"
private const val KEY_LOG_MAX_MB = "log_max_mb"
private const val KEY_BUFFER_KB = "buf_kb"
private const val KEY_POOL_SIZE = "pool_size"
private const val KEY_CHECK_UPDATES = "check_updates"
private const val KEY_VERBOSE = "verbose"
}
}
@@ -0,0 +1,102 @@
package org.flowseal.tgwsproxy
import android.content.Context
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
import org.json.JSONObject
object PythonProxyBridge {
private const val MODULE_NAME = "android_proxy_bridge"
private val pythonStartLock = Any()
fun start(context: Context, config: NormalizedProxyConfig): String {
val module = getModule(context)
return module.callAttr(
"start_proxy",
File(context.filesDir, "tg-ws-proxy").absolutePath,
config.host,
config.port,
config.secret,
config.dcIpList,
config.logMaxMb,
config.bufferKb,
config.poolSize,
config.verbose,
).toString()
}
fun stop(context: Context) {
if (!Python.isStarted()) {
return
}
getModule(context).callAttr("stop_proxy")
}
fun getTrafficStats(context: Context): ProxyTrafficStats {
if (!Python.isStarted()) {
return ProxyTrafficStats()
}
val payload = getModule(context).callAttr("get_runtime_stats_json").toString()
val json = JSONObject(payload)
return ProxyTrafficStats(
bytesUp = json.optLong("bytes_up", 0L),
bytesDown = json.optLong("bytes_down", 0L),
running = json.optBoolean("running", false),
lastError = json.optString("last_error").ifBlank { null },
)
}
fun getUpdateStatus(context: Context, checkNow: Boolean = false): ProxyUpdateStatus {
val payload = getModule(context).callAttr("get_update_status_json", checkNow).toString()
val json = JSONObject(payload)
return ProxyUpdateStatus(
currentVersion = json.optString("current_version").ifBlank { "unknown" },
latestVersion = json.optString("latest").ifBlank { null },
hasUpdate = json.optBoolean("has_update", false),
aheadOfRelease = json.optBoolean("ahead_of_release", false),
checked = json.optBoolean("checked", false),
htmlUrl = json.optString("html_url").ifBlank { null },
error = json.optString("error").ifBlank { null },
)
}
private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME)
private fun getPython(context: Context): Python {
if (Python.isStarted()) {
return Python.getInstance()
}
synchronized(pythonStartLock) {
if (!Python.isStarted()) {
try {
Python.start(AndroidPlatform(context))
} catch (exc: IllegalStateException) {
if (!Python.isStarted()) {
throw exc
}
}
}
}
return Python.getInstance()
}
}
data class ProxyTrafficStats(
val bytesUp: Long = 0L,
val bytesDown: Long = 0L,
val running: Boolean = false,
val lastError: String? = null,
)
data class ProxyUpdateStatus(
val currentVersion: String = "unknown",
val latestVersion: String? = null,
val hasUpdate: Boolean = false,
val aheadOfRelease: Boolean = false,
val checked: Boolean = false,
val htmlUrl: String? = null,
val error: String? = null,
)
@@ -0,0 +1,23 @@
package org.flowseal.tgwsproxy
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
object TelegramProxyIntent {
fun open(context: Context, config: NormalizedProxyConfig): Boolean {
val uri = Uri.parse(
"tg://proxy?server=${Uri.encode(config.host)}&port=${config.port}&secret=dd${Uri.encode(config.secret)}"
)
val intent = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
}
}
@@ -0,0 +1,161 @@
import os
import threading
import time
import json
from pathlib import Path
from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime
from proxy import __version__
import proxy.tg_ws_proxy as tg_ws_proxy
RELEASES_PAGE_URL = "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
_RUNTIME_LOCK = threading.RLock()
_RUNTIME: Optional[ProxyAppRuntime] = None
_LAST_ERROR: Optional[str] = None
def _remember_error(message: str) -> None:
global _LAST_ERROR
_LAST_ERROR = message
def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]:
if dc_ip_list is None:
return []
values: list[object]
try:
values = list(dc_ip_list)
except TypeError:
# Chaquopy may expose Kotlin's List<String> as java.util.ArrayList,
# which isn't always directly iterable from Python.
if hasattr(dc_ip_list, "toArray"):
values = list(dc_ip_list.toArray())
elif hasattr(dc_ip_list, "size") and hasattr(dc_ip_list, "get"):
size = int(dc_ip_list.size())
values = [dc_ip_list.get(i) for i in range(size)]
else:
values = [dc_ip_list]
return [str(item).strip() for item in values if str(item).strip()]
def start_proxy(app_dir: str, host: str, port: int, secret: str,
dc_ip_list: Iterable[object], log_max_mb: float = 5.0,
buf_kb: int = 256, pool_size: int = 4,
verbose: bool = False) -> str:
global _RUNTIME, _LAST_ERROR
with _RUNTIME_LOCK:
if _RUNTIME is not None:
_RUNTIME.stop_proxy()
_RUNTIME = None
_LAST_ERROR = None
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
tg_ws_proxy.reset_stats()
runtime = ProxyAppRuntime(
Path(app_dir),
logger_name="tg-ws-android",
on_error=_remember_error,
)
runtime.reset_log_file()
runtime.setup_logging(verbose=verbose, log_max_mb=float(log_max_mb))
config = {
"host": host,
"port": int(port),
"secret": str(secret).strip(),
"dc_ip": _normalize_dc_ip_list(dc_ip_list),
"log_max_mb": float(log_max_mb),
"buf_kb": int(buf_kb),
"pool_size": int(pool_size),
"verbose": bool(verbose),
}
runtime.save_config(config)
if not runtime.start_proxy(config):
_RUNTIME = None
raise RuntimeError(_LAST_ERROR or "Failed to start proxy runtime.")
_RUNTIME = runtime
# Give the proxy thread a short warm-up window so immediate bind failures
# surface before Kotlin reports the service as running.
for _ in range(10):
time.sleep(0.1)
with _RUNTIME_LOCK:
if _LAST_ERROR:
runtime.stop_proxy()
_RUNTIME = None
raise RuntimeError(_LAST_ERROR)
if runtime.is_proxy_running():
return str(runtime.log_file)
with _RUNTIME_LOCK:
runtime.stop_proxy()
_RUNTIME = None
raise RuntimeError("Proxy runtime did not become ready in time.")
def stop_proxy() -> None:
global _RUNTIME, _LAST_ERROR
with _RUNTIME_LOCK:
_LAST_ERROR = None
if _RUNTIME is not None:
_RUNTIME.stop_proxy()
_RUNTIME = None
def is_running() -> bool:
with _RUNTIME_LOCK:
return bool(_RUNTIME and _RUNTIME.is_proxy_running())
def get_last_error() -> Optional[str]:
return _LAST_ERROR
def get_runtime_stats_json() -> str:
with _RUNTIME_LOCK:
running = bool(_RUNTIME and _RUNTIME.is_proxy_running())
payload = dict(tg_ws_proxy.get_stats_snapshot())
payload["running"] = running
payload["last_error"] = _LAST_ERROR
return json.dumps(payload)
def _load_update_check():
from utils import update_check
return update_check
def get_update_status_json(check_now: bool = False) -> str:
payload = {
"current_version": __version__,
"latest": "",
"has_update": False,
"ahead_of_release": False,
"checked": False,
"html_url": RELEASES_PAGE_URL,
"error": "",
}
try:
update_check = _load_update_check()
if check_now:
update_check.run_check(__version__)
payload.update(update_check.get_status())
payload["current_version"] = __version__
payload["latest"] = payload.get("latest") or ""
payload["html_url"] = payload.get("html_url") or update_check.RELEASES_PAGE_URL
payload["error"] = payload.get("error") or ""
except Exception as exc:
payload["error"] = str(exc)
return json.dumps(payload)
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1E88E5"
android:pathData="M54,10A44,44 0 1,1 10,54A44,44 0 0,1 54,10Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M33,34h42v10H59v30H49V44H33z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,4h14v3h-5v12h-4V7H5z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8,20h8v2H8z" />
</vector>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/logs_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/logs_subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/logs_path_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/logPathValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/logContentValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textIsSelectable="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/refreshLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/refresh_logs_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/closeLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/close_logs_button" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,424 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/statusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/status_stopped"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/serviceHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/service_hint_idle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:id="@+id/lastErrorCard"
android:visibility="gone"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/last_error_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorError" />
<TextView
android:id="@+id/lastErrorValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorError" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/updates_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/currentVersionValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/checkUpdatesSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/updates_check_label" />
<TextView
android:id="@+id/updateStatusValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/updates_status_initial"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openReleasePageButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/updates_open_release_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/system_status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/systemStatusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/system_status_attention"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/systemStatusHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableBatteryOptimizationButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/disable_battery_optimization_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openAppSettingsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_app_settings_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/endpoint_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:hint="@string/host_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/hostInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/port_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/portInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/secret_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/secretInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/dc_ip_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dcIpInput"
android:layout_width="match_parent"
android:layout_height="140dp"
android:gravity="top|start"
android:inputType="textMultiLine"
android:minLines="5" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/advanced_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/verboseSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/verbose_label" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/log_max_mb_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/logMaxMbInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/buffer_kb_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/bufferKbInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/pool_size_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/poolSizeInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/errorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/actions_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="@string/stop_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/restartButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/restart_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openTelegramButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_telegram_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_logs_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="proxy_blue">#1E88E5</color>
<color name="proxy_navy">#0B1F33</color>
<color name="proxy_surface">#F4F8FC</color>
<color name="proxy_white">#FFFFFF</color>
</resources>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TG WS Proxy</string>
<string name="subtitle">Android app for the local Telegram MTProto proxy.</string>
<string name="status_label">Foreground service</string>
<string name="status_starting">Starting</string>
<string name="status_running">Running</string>
<string name="status_stopped">Stopped</string>
<string name="service_hint_idle">Configure the proxy settings, then start the foreground service.</string>
<string name="service_hint_starting">Starting embedded Python proxy for %1$s:%2$d.</string>
<string name="service_hint_running">Foreground service active for %1$s:%2$d.</string>
<string name="system_status_label">Android background limits</string>
<string name="system_status_ready">Ready</string>
<string name="system_status_attention">Needs attention</string>
<string name="system_check_battery_ignored">Battery optimization: disabled for this app.</string>
<string name="system_check_battery_active">Battery optimization: still enabled, Android may stop the proxy in background.</string>
<string name="system_check_background_ok">Background restriction: not detected.</string>
<string name="system_check_background_restricted">Background restriction: enabled, Android may block long-running work.</string>
<string name="system_check_oem_note">Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode.</string>
<string name="endpoint_section_label">Proxy endpoint</string>
<string name="advanced_section_label">Advanced</string>
<string name="actions_section_label">Actions</string>
<string name="updates_label">Updates</string>
<string name="updates_current_version_format">v%1$s</string>
<string name="updates_check_label">Check for updates on launch</string>
<string name="updates_status_initial">Checking GitHub release…</string>
<string name="updates_status_idle">Not checked yet</string>
<string name="updates_status_disabled">Automatic update checks are disabled.</string>
<string name="updates_status_latest">Installed version %1$s matches the latest GitHub release.</string>
<string name="updates_status_newer">Installed version %1$s is newer than the latest GitHub release.</string>
<string name="updates_status_available">Version %1$s is available on GitHub (installed: %2$s).</string>
<string name="updates_status_error">Update check failed: %1$s</string>
<string name="updates_open_release_button">Open Release Page</string>
<string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string>
<string name="secret_hint">MTProto secret (32 hex characters)</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
<string name="log_max_mb_hint">Max log size before rotation (MB)</string>
<string name="buffer_kb_hint">Socket buffer size (KB)</string>
<string name="pool_size_hint">WS pool size per DC</string>
<string name="verbose_label">Verbose logging</string>
<string name="save_button">Save Settings</string>
<string name="start_button">Start Service</string>
<string name="stop_button">Stop Service</string>
<string name="restart_button">Restart Proxy</string>
<string name="open_telegram_button">Open in Telegram</string>
<string name="open_logs_button">Open Logs</string>
<string name="disable_battery_optimization_button">Disable Battery Optimization</string>
<string name="open_app_settings_button">Open App Settings</string>
<string name="last_error_label">Last service error</string>
<string name="release_page_open_failed">Failed to open release page.</string>
<string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string>
<string name="service_restart_requested">Foreground service restart requested</string>
<string name="telegram_not_found">Telegram app was not found for tg://proxy.</string>
<string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string>
<string name="notification_starting">MTProto %1$s:%2$d • starting embedded Python</string>
<string name="notification_running">MTProto %1$s:%2$d • proxy active</string>
<string name="notification_endpoint">%1$s:%2$d</string>
<string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
<string name="notification_action_stop">Stop</string>
<string name="saved_config_invalid">Saved proxy settings are invalid.</string>
<string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>
<string name="proxy_runtime_stopped_unexpectedly">Proxy runtime stopped unexpectedly.</string>
<string name="logs_title">Proxy Logs</string>
<string name="logs_subtitle">Shows the latest lines from the embedded Python proxy log.</string>
<string name="logs_path_label">Log file</string>
<string name="refresh_logs_button">Refresh Logs</string>
<string name="close_logs_button">Close</string>
<string name="logs_empty">The log file is empty or has not been created yet.</string>
<string name="logs_read_failed">Failed to read log file: %1$s</string>
<string name="logs_truncated_prefix">Showing the last part of the log file.</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.TgWsProxy" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/proxy_blue</item>
<item name="colorSecondary">@color/proxy_navy</item>
<item name="android:statusBarColor">@color/proxy_navy</item>
<item name="android:navigationBarColor">@color/proxy_white</item>
<item name="colorSurface">@color/proxy_surface</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_BUILD_DIR="$ROOT_DIR/app/build"
if [[ -z "${GRADLE_USER_HOME:-}" ]]; then
if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then
export GRADLE_USER_HOME="$HOME/.gradle"
else
export GRADLE_USER_HOME="$ROOT_DIR/.gradle-local"
fi
fi
mkdir -p "$GRADLE_USER_HOME"
if [[ -d "$HOME/.local/jdk" ]]; then
export JAVA_HOME="$HOME/.local/jdk"
fi
if [[ -d "$HOME/android-sdk" ]]; then
export ANDROID_SDK_ROOT="$HOME/android-sdk"
fi
if [[ -n "${JAVA_HOME:-}" ]]; then
export PATH="$JAVA_HOME/bin:$PATH"
fi
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH"
fi
if [[ -d "$HOME/.local/gradle/gradle-8.7/bin" ]]; then
export PATH="$HOME/.local/gradle/gradle-8.7/bin:$PATH"
fi
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy
GRADLE_BIN="gradle"
if [[ -x "$ROOT_DIR/gradlew" ]]; then
GRADLE_BIN="$ROOT_DIR/gradlew"
fi
GRADLE_RUN_DIR="$ROOT_DIR"
ATTEMPTS="${ATTEMPTS:-5}"
SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
TASK="${1:-assembleStandardDebug}"
LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}"
CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}"
running_on_wsl_windows_mount() {
[[ -n "${WSL_DISTRO_NAME:-}" && "$ROOT_DIR" == /mnt/* ]]
}
prepare_wsl_build_dir() {
if ! running_on_wsl_windows_mount; then
return 0
fi
local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/tg-ws-proxy-android-build"
local project_key
project_key="$(printf '%s' "$ROOT_DIR" | sha256sum | cut -d' ' -f1)"
local linux_build_dir="$cache_root/$project_key/app-build"
mkdir -p "$cache_root"
mkdir -p "$linux_build_dir"
if [[ -L "$APP_BUILD_DIR" ]]; then
return 0
fi
if [[ -e "$APP_BUILD_DIR" ]]; then
rm -rf "$APP_BUILD_DIR"
fi
ln -s "$linux_build_dir" "$APP_BUILD_DIR"
}
task_uses_legacy32() {
[[ "$TASK" =~ [Ll]egacy32 ]]
}
task_uses_standard() {
if [[ "$TASK" =~ [Ss]tandard ]]; then
return 0
fi
if task_uses_legacy32; then
return 1
fi
return 0
}
prefetch_artifact() {
local relative_path="$1"
local destination="$LOCAL_CHAQUOPY_REPO/$relative_path"
if [[ -f "$destination" ]]; then
return 0
fi
mkdir -p "$(dirname "$destination")"
echo "Prefetching $relative_path"
curl \
--fail \
--location \
--retry 8 \
--retry-all-errors \
--continue-at - \
--connect-timeout 15 \
--speed-limit 1024 \
--speed-time 20 \
--max-time 90 \
--output "$destination" \
"$CHAQUOPY_MAVEN_BASE/$relative_path"
}
prefetch_chaquopy_runtime() {
local artifacts=(
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.pom"
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.jar"
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0.pom"
)
if task_uses_standard; then
artifacts+=(
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-arm64-v8a.so"
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-x86_64.so"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0.pom"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-arm64-v8a.zip"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib-pyc.zip"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip"
)
fi
if task_uses_legacy32; then
artifacts+=(
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.11-armeabi-v7a.so"
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0.pom"
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-armeabi-v7a.zip"
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib-pyc.zip"
"com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib.zip"
)
fi
for artifact in "${artifacts[@]}"; do
prefetch_artifact "$artifact"
done
}
cleanup_stale_build_state() {
local stale_dirs=(
"$APP_BUILD_DIR/python/env"
"$APP_BUILD_DIR/intermediates/project_dex_archive"
"$APP_BUILD_DIR/intermediates/desugar_graph"
"$APP_BUILD_DIR/tmp/kotlin-classes"
"$APP_BUILD_DIR/snapshot/kotlin"
)
for stale_dir in "${stale_dirs[@]}"; do
if [[ -d "$stale_dir" ]]; then
rm -rf "$stale_dir"
fi
done
}
prefetch_chaquopy_runtime
prepare_wsl_build_dir
for attempt in $(seq 1 "$ATTEMPTS"); do
echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)"
if (
cd "$GRADLE_RUN_DIR"
"$GRADLE_BIN" --no-daemon --console=plain "$TASK"
); then
exit 0
fi
if [[ "$attempt" -lt "$ATTEMPTS" ]]; then
cleanup_stale_build_state
echo "Build failed, retrying in ${SLEEP_SECONDS}s..."
sleep "$SLEEP_SECONDS"
fi
done
echo "Android build failed after $ATTEMPTS attempts."
exit 1
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("com.chaquo.python") version "17.0.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
systemProp.org.gradle.internal.http.connectionTimeout=120000
systemProp.org.gradle.internal.http.socketTimeout=120000
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
if (localChaquopyRepo.isDirectory) {
maven(url = localChaquopyRepo.toURI())
}
maven("https://chaquo.com/maven")
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
if (localChaquopyRepo.isDirectory) {
maven(url = localChaquopyRepo.toURI())
}
maven("https://chaquo.com/maven")
google()
mavenCentral()
}
}
rootProject.name = "tg-ws-proxy-android"
include(":app")
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 473 B

+159 -563
View File
@@ -1,350 +1,44 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import logging.handlers
import os
import subprocess
import sys
import threading
import webbrowser
import time
from pathlib import Path
from typing import Dict, Optional
from typing import Optional
import customtkinter as ctk
import psutil
import pyperclip
import pystray
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageTk
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, 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,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
populate_first_run_window,
tray_settings_scroll_and_footer,
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
create_ctk_root,
ctk_theme_for_platform,
main_content_frame,
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
_exiting = False
log = logging.getLogger("tg-ws-tray")
# dialogs (tkinter messagebox)
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(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",
)
)
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"
)
)
root.addHandler(ch)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse(
[margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55)
)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _apply_linux_ctk_window_icon(root) -> None:
"""PhotoImage храним на root — иначе GC может убрать картинку до закрытия окна."""
icon_img = _load_icon()
if icon_img:
from PIL import ImageTk
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
def _run_proxy_thread(
port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1"
):
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)
)
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
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)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True,
name="proxy",
)
_proxy_thread.start()
def stop_proxy():
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showerror(title, text, parent=root)
root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showinfo(title, text, parent=root)
root.destroy()
def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
def _msgbox(kind: str, text: str, title: str, **kw):
import tkinter as _tk
from tkinter import messagebox as _mb
@@ -354,273 +48,189 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
root.attributes("-topmost", True)
except Exception:
pass
r = _mb.askyesno(title, text, parent=root)
result = getattr(_mb, kind)(title, text, parent=root, **kw)
root.destroy()
return bool(r)
return result
def _maybe_notify_update_async():
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
text = (
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?"
)
if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
_msgbox("showerror", text, title)
def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
_msgbox("showinfo", text, title)
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return bool(_msgbox("askyesno", text, title))
def _apply_window_icon(root) -> None:
icon_img = load_icon()
if icon_img:
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
# tray callbacks
def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying %s", url)
try:
pyperclip.copy(url)
_show_info(
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy",
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
try:
pyperclip.copy(url)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_edit_config(icon=None, item=None):
def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error), daemon=True
).start()
def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog():
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_root(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
)
def on_save():
merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
_show_error(merged)
return
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None):
def _on_open_logs(icon=None, item=None) -> None:
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")}
subprocess.Popen(
["xdg-open", str(LOG_FILE)],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
["xdg-open", str(LOG_FILE)], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True,
)
else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy")
_show_info("Файл логов ещё не создан.")
def _on_exit(icon=None, item=None):
def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
quit_ctk()
threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
if icon:
icon.stop()
def _show_first_run():
_ensure_dirs()
# settings dialog
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk):
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
after_create=_apply_window_icon,
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
def _finish() -> None:
root.destroy()
done.set()
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
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu()
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
ctk_run_dialog(_build)
# first run
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
if not ensure_ctk_thread(ctk):
FIRST_RUN_MARKER.touch()
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
if ctk is None:
FIRST_RUN_MARKER.touch()
return
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
after_create=_apply_window_icon,
)
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
def on_done(open_tg: bool) -> None:
FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
root = create_ctk_root(
ctk,
title="TG WS Proxy",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
ctk_run_dialog(_build)
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy",
)
# tray menu
def _build_menu():
if pystray is None:
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)
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True
),
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
@@ -630,27 +240,18 @@ def _build_menu():
)
def run_tray():
# entry point
def run_tray() -> None:
global _tray_icon, _config
_config = load_config()
save_config(_config)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
bootstrap(_config)
if pystray is None or Image is None:
log.error("pystray or Pillow not installed; running in console mode")
start_proxy()
start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
@@ -658,16 +259,12 @@ def run_tray():
stop_proxy()
return
start_proxy()
_maybe_notify_update_async()
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_show_first_run()
_check_ipv6_warning()
icon_image = _load_icon()
_tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu())
check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
@@ -675,15 +272,14 @@ def run_tray():
log.info("Tray app exited")
def main():
if not _acquire_lock():
def main() -> None:
if not acquire_lock("linux.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
try:
run_tray()
finally:
_release_lock()
release_lock()
if __name__ == "__main__":
+225 -377
View File
@@ -1,18 +1,13 @@
from __future__ import annotations
import json
import logging
import logging.handlers
import os
import psutil
import subprocess
import sys
import threading
import time
import webbrowser
import asyncio as _asyncio
from pathlib import Path
from typing import Dict, Optional
from typing import Optional
try:
import rumps
@@ -31,257 +26,134 @@ except ImportError:
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
APP_NAME = "TgWsProxy"
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
from utils.tray_common import (
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
)
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_app: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray")
# osascript dialogs
# Single-instance lock
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _esc(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _osascript(script: str) -> str:
r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
return r.stdout.strip()
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {"create_time": proc.create_time()}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
# Filesystem helpers
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
encoding='utf-8',
def _show_error(text: str, title: str = "TG WS Proxy") -> None:
_osascript(
f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
f'buttons {{"OK"}} default button "OK" with icon stop'
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
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"))
root.addHandler(ch)
# Menubar icon
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
_osascript(
f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
f'buttons {{"OK"}} default button "OK" with icon note'
)
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _ask_yes_no_close(text, title) is True
def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]:
r = subprocess.run(
[
"osascript", "-e",
f'button returned of (display dialog "{_esc(text)}" '
f'with title "{_esc(title)}" '
f'buttons {{"Закрыть", "Нет", "Да"}} '
f'default button "Да" cancel button "Закрыть" with icon note)',
],
capture_output=True, text=True,
)
if r.returncode != 0:
return None
btn = r.stdout.strip()
if btn == "Да":
return True
if btn == "Нет":
return False
return None
def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> Optional[str]:
r = subprocess.run(
[
"osascript", "-e",
f'text returned of (display dialog "{_esc(prompt)}" '
f'default answer "{_esc(default)}" '
f'with title "{_esc(title)}" '
f'buttons {{"Закрыть", "OK"}} '
f'default button "OK" cancel button "Закрыть")',
],
capture_output=True, text=True,
)
if r.returncode != 0:
return None
return r.stdout.rstrip("\r\n")
# menubar icon
def _make_menubar_icon(size: int = 44):
if Image is None:
return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 0, 0, 255))
draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
try:
font = ImageFont.truetype(
"/System/Library/Fonts/Helvetica.ttc",
size=int(size * 0.55))
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55))
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
draw.text(
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
"T", fill=(255, 255, 255, 255), font=font,
)
return img
# Generate menubar icon PNG if it does not exist.
def _ensure_menubar_icon():
def _ensure_menubar_icon() -> None:
if MENUBAR_ICON_PATH.exists():
return
_ensure_dirs()
ensure_dirs()
img = _make_menubar_icon(44)
if img:
img.save(str(MENUBAR_ICON_PATH), "PNG")
# Native macOS dialogs
# proxy lifecycle (macOS-local)
def _escape_osascript_text(text: str) -> str:
return text.replace('\\', '\\\\').replace('"', '\\"')
import asyncio as _asyncio
def _osascript(script: str) -> str:
r = subprocess.run(
['osascript', '-e', script],
capture_output=True, text=True)
return r.stdout.strip()
def _show_error(text: str, title: str = "TG WS Proxy"):
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon stop')
def _show_info(text: str, title: str = "TG WS Proxy"):
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
_osascript(
f'display dialog "{text_esc}" with title "{title_esc}" '
f'buttons {{"OK"}} default button "OK" with icon note')
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
result = _ask_yes_no_close(text, title)
return result is True
def _ask_yes_no_close(text: str,
title: str = "TG WS Proxy") -> Optional[bool]:
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
r = subprocess.run(
['osascript', '-e',
f'button returned of (display dialog "{text_esc}" '
f'with title "{title_esc}" '
f'buttons {{"Закрыть", "Нет", "Да"}} '
f'default button "Да" cancel button "Закрыть" with icon note)'],
capture_output=True, text=True)
if r.returncode != 0:
return None
result = r.stdout.strip()
if result == "Да":
return True
if result == "Нет":
return False
return None
# Proxy lifecycle
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
host: str = '127.0.0.1'):
def _run_proxy_thread() -> None:
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
@@ -289,47 +161,28 @@ def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите.")
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
def _start_proxy() -> None:
global _proxy_thread
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
if not apply_proxy_config(_config):
_show_error("Ошибка конфигурации DC → IP.")
return
log.info("Starting proxy on %s:%d ...", host, port)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
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)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
pc = tg_ws_proxy.proxy_config
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy():
def _stop_proxy() -> None:
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
@@ -340,22 +193,21 @@ def stop_proxy():
log.info("Proxy stopped")
def restart_proxy():
def _restart_proxy() -> None:
log.info("Restarting proxy...")
stop_proxy()
_stop_proxy()
time.sleep(0.3)
start_proxy()
_start_proxy()
# Menu callbacks
# menu callbacks
def _on_open_in_telegram(_=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
def _on_open_in_telegram(_=None) -> None:
url = tg_proxy_url(_config)
log.info("Opening %s", url)
try:
result = subprocess.call(['open', url])
result = subprocess.call(["open", url])
if result != 0:
raise RuntimeError("open command failed")
except Exception:
@@ -369,67 +221,58 @@ def _on_open_in_telegram(_=None):
if pyperclip:
pyperclip.copy(url)
else:
subprocess.run(['pbcopy'], input=url.encode(),
check=True)
subprocess.run(["pbcopy"], input=url.encode(), check=True)
_show_info(
"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена:\n{url}")
f"Ссылка скопирована в буфер обмена:\n{url}"
)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(_=None):
def _do_restart():
def _on_copy_link(_=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
try:
if pyperclip:
pyperclip.copy(url)
else:
subprocess.run(["pbcopy"], input=url.encode(), check=True)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(_=None) -> None:
def _do():
global _config
_config = load_config()
if _app:
_app.update_menu_title()
restart_proxy()
_restart_proxy()
threading.Thread(target=_do_restart, daemon=True).start()
threading.Thread(target=_do, daemon=True).start()
def _on_open_logs(_=None):
def _on_open_logs(_=None) -> None:
log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists():
subprocess.call(['open', str(LOG_FILE)])
subprocess.call(["open", str(LOG_FILE)])
else:
_show_info("Файл логов ещё не создан.")
# Show a native text input dialog. Returns None if cancelled.
def _osascript_input(prompt: str, default: str,
title: str = "TG WS Proxy") -> Optional[str]:
prompt_esc = _escape_osascript_text(prompt)
default_esc = _escape_osascript_text(default)
title_esc = _escape_osascript_text(title)
r = subprocess.run(
['osascript', '-e',
f'text returned of (display dialog "{prompt_esc}" '
f'default answer "{default_esc}" '
f'with title "{title_esc}" '
f'buttons {{"Закрыть", "OK"}} '
f'default button "OK" cancel button "Закрыть")'],
capture_output=True, text=True)
if r.returncode != 0:
return None
return r.stdout.rstrip("\r\n")
def _on_edit_config(_=None):
def _on_edit_config(_=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _check_updates_menu_title() -> str:
on = bool(_config.get("check_updates", True))
return (
"✓ Проверять обновления при запуске"
if on
else "Проверять обновления при запуске (выкл)"
)
return "✓ Проверять обновления при запуске" if on else "Проверять обновления при запуске (выкл)"
def _toggle_check_updates(_=None):
def _toggle_check_updates(_=None) -> None:
global _config
_config["check_updates"] = not bool(_config.get("check_updates", True))
save_config(_config)
@@ -437,12 +280,15 @@ def _toggle_check_updates(_=None):
_app._check_updates_item.title = _check_updates_menu_title()
def _on_open_release_page(_=None):
def _on_open_release_page(_=None) -> None:
from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL)
def _maybe_notify_update_async():
# update check
def _maybe_notify_update_async() -> None:
def _work():
time.sleep(1.5)
if _exiting:
@@ -458,8 +304,7 @@ def _maybe_notify_update_async():
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if _ask_yes_no(
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?",
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
):
webbrowser.open(url)
@@ -469,18 +314,16 @@ def _maybe_notify_update_async():
threading.Thread(target=_work, daemon=True, name="update-check").start()
# Settings via native macOS dialogs
def _edit_config_dialog():
# settings dialog
def _edit_config_dialog() -> None:
cfg = load_config()
# Host
host = _osascript_input(
"IP-адрес прокси:",
cfg.get("host", DEFAULT_CONFIG["host"]))
host = _osascript_input("IP-адрес прокси:", cfg.get("host", DEFAULT_CONFIG["host"]))
if host is None:
return
host = host.strip()
import socket as _sock
try:
_sock.inet_aton(host)
@@ -488,10 +331,7 @@ def _edit_config_dialog():
_show_error("Некорректный IP-адрес.")
return
# Port
port_str = _osascript_input(
"Порт прокси:",
str(cfg.get("port", DEFAULT_CONFIG["port"])))
port_str = _osascript_input("Порт прокси:", str(cfg.get("port", DEFAULT_CONFIG["port"])))
if port_str is None:
return
try:
@@ -502,42 +342,49 @@ def _edit_config_dialog():
_show_error("Порт должен быть числом 1-65535")
return
# DC-IP mappings
secret_str = _osascript_input(
"MTProto Secret (32 hex символа):", cfg.get("secret", DEFAULT_CONFIG["secret"])
)
if secret_str is None:
return
secret_str = secret_str.strip().lower()
if len(secret_str) != 32 or not all(c in "0123456789abcdef" for c in secret_str):
_show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.")
return
dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))
dc_str = _osascript_input(
"DC → IP маппинги (через запятую, формат DC:IP):\n"
"Например: 2:149.154.167.220, 4:149.154.167.220",
dc_default)
dc_default,
)
if dc_str is None:
return
dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines()
if s.strip()]
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)
except ValueError as e:
_show_error(str(e))
return
# Verbose
verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?")
if verbose is None:
return
# Advanced settings
adv_str = _osascript_input(
"Расширенные настройки (буфер KB, WS пул, лог MB):\n"
"Формат: buf_kb,pool_size,log_max_mb",
f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])},"
f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])},"
f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}")
f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}",
)
if adv_str is None:
return
adv = {}
if adv_str:
parts = [s.strip() for s in adv_str.split(',')]
keys = [("buf_kb", int), ("pool_size", int),
("log_max_mb", float)]
parts = [s.strip() for s in adv_str.split(",")]
keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)]
for i, (k, typ) in enumerate(keys):
if i < len(parts):
try:
@@ -548,11 +395,13 @@ def _edit_config_dialog():
new_cfg = {
"host": host,
"port": port,
"secret": secret_str,
"dc_ip": dc_lines,
"verbose": verbose,
"buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])),
"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),
}
save_config(new_cfg)
log.info("Config saved: %s", new_cfg)
@@ -562,21 +411,23 @@ def _edit_config_dialog():
if _app:
_app.update_menu_title()
if _ask_yes_no_close(
"Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
restart_proxy()
if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
_restart_proxy()
# First-run & IPv6 dialogs
# first run & ipv6
def _show_first_run():
_ensure_dirs()
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}"
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
tg_url = tg_proxy_url(_config)
link_host = tg_ws_proxy.get_link_host(host)
text = (
f"Прокси запущен и работает в строке меню.\n\n"
@@ -586,54 +437,54 @@ def _show_first_run():
f" Или ссылка: {tg_url}\n\n"
f"Вручную:\n"
f" Настройки → Продвинутые → Тип подключения → Прокси\n"
f" SOCKS5{host} : {port} (без логина/пароля)\n\n"
f" MTProto{link_host} : {port} \n"
f" Secret: dd{secret} \n\n"
f"Открыть прокси в Telegram сейчас?"
)
FIRST_RUN_MARKER.touch()
if _ask_yes_no(text, "TG WS Proxy"):
_on_open_in_telegram()
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
def _check_ipv6_warning() -> None:
ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
import socket as _sock
has = False
try:
for addr in _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6):
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
has = True
break
except Exception:
pass
if not has:
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
has = True
except Exception:
pass
if not has:
return
IPV6_WARN_MARKER.touch()
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
"Это предупреждение будет показано только один раз.")
"Это предупреждение будет показано только один раз."
)
# rumps menubar app
# rumps app
_TgWsProxyAppBase = rumps.App if rumps else object
@@ -641,33 +492,26 @@ _TgWsProxyAppBase = rumps.App if rumps else object
class TgWsProxyApp(_TgWsProxyAppBase):
def __init__(self):
_ensure_menubar_icon()
icon_path = (str(MENUBAR_ICON_PATH)
if MENUBAR_ICON_PATH.exists() else None)
icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
self._open_tg_item = rumps.MenuItem(
f"Открыть в Telegram ({host}:{port})",
callback=_on_open_in_telegram)
self._restart_item = rumps.MenuItem(
"Перезапустить прокси",
callback=_on_restart)
self._settings_item = rumps.MenuItem(
"Настройки...",
callback=_on_edit_config)
self._logs_item = rumps.MenuItem(
"Открыть логи",
callback=_on_open_logs)
f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
)
self._copy_link_item = rumps.MenuItem("Скопировать ссылку", callback=_on_copy_link)
self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart)
self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config)
self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs)
self._release_page_item = rumps.MenuItem(
"Страница релиза на GitHub…",
callback=_on_open_release_page)
"Страница релиза на GitHub…", callback=_on_open_release_page
)
self._check_updates_item = rumps.MenuItem(
_check_updates_menu_title(),
callback=_toggle_check_updates)
self._version_item = rumps.MenuItem(
f"Версия {__version__}",
callback=lambda _: None)
_check_updates_menu_title(), callback=_toggle_check_updates
)
self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None)
super().__init__(
"TG WS Proxy",
@@ -676,6 +520,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
quit_button="Выход",
menu=[
self._open_tg_item,
self._copy_link_item,
None,
self._restart_item,
self._settings_item,
@@ -685,16 +530,20 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._check_updates_item,
None,
self._version_item,
])
],
)
def update_menu_title(self):
def update_menu_title(self) -> None:
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item.title = (
f"Открыть в Telegram ({host}:{port})")
link_host = tg_ws_proxy.get_link_host(host)
self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
def run_menubar():
# entry point
def run_menubar() -> None:
global _app, _config
_config = load_config()
@@ -706,26 +555,26 @@ def run_menubar():
except Exception:
pass
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
setup_logging(
_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s, menubar app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if rumps is None or Image is None:
log.error("rumps or Pillow not installed; running in console mode")
start_proxy()
_start_proxy()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
stop_proxy()
_stop_proxy()
return
start_proxy()
_start_proxy()
_maybe_notify_update_async()
_show_first_run()
_check_ipv6_warning()
@@ -733,19 +582,18 @@ def run_menubar():
log.info("Menubar app running")
_app.run()
stop_proxy()
_stop_proxy()
log.info("Menubar app exited")
def main():
if not _acquire_lock():
def main() -> None:
if not acquire_lock("macos.py"):
_show_info("Приложение уже запущено.")
return
try:
run_menubar()
finally:
_release_lock()
release_lock()
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.3.0"
__version__ = "1.4.0"
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import asyncio as _asyncio
import json
import logging
import logging.handlers
import os
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Dict, Optional
import proxy.tg_ws_proxy as tg_ws_proxy
DEFAULT_CONFIG = {
"port": 1443,
"host": "127.0.0.1",
"secret": os.urandom(16).hex(),
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,
"verbose": False,
}
class ProxyAppRuntime:
def __init__(self, app_dir: Path,
default_config: Optional[dict] = None,
logger_name: str = "tg-ws-runtime",
on_error: Optional[Callable[[str], None]] = None,
parse_dc_ip_list: Optional[
Callable[[list[str]], Dict[int, str]]
] = None,
run_proxy: Optional[Callable[..., object]] = None,
thread_factory: Optional[Callable[..., object]] = None):
self.app_dir = Path(app_dir)
self.config_file = self.app_dir / "config.json"
self.log_file = self.app_dir / "proxy.log"
self.default_config = dict(default_config or DEFAULT_CONFIG)
self.log = logging.getLogger(logger_name)
self.on_error = on_error
self.parse_dc_ip_list = parse_dc_ip_list or \
tg_ws_proxy.parse_dc_ip_list
self.run_proxy = run_proxy or tg_ws_proxy._run
self.thread_factory = thread_factory or threading.Thread
self.config: dict = {}
self._proxy_thread = None
self._async_stop = None
def _build_core_config(self, active_cfg: dict, dc_opt: Dict[int, str]):
port = int(active_cfg.get("port", self.default_config["port"]))
host = str(active_cfg.get("host", self.default_config["host"]))
secret = str(active_cfg.get("secret") or "").strip()
if not secret:
secret = os.urandom(16).hex()
active_cfg["secret"] = secret
buf_kb = int(active_cfg.get("buf_kb", self.default_config["buf_kb"]))
pool_size = int(active_cfg.get(
"pool_size", self.default_config["pool_size"]))
return tg_ws_proxy.ProxyConfig(
port=port,
host=host,
secret=secret,
dc_redirects=dc_opt,
buffer_size=max(4, buf_kb) * 1024,
pool_size=max(0, pool_size),
)
def ensure_dirs(self):
self.app_dir.mkdir(parents=True, exist_ok=True)
def load_config(self) -> dict:
self.ensure_dirs()
if self.config_file.exists():
try:
with open(self.config_file, "r", encoding="utf-8") as f:
data = json.load(f)
for key, value in self.default_config.items():
data.setdefault(key, value)
self.config = data
return data
except Exception as exc:
self.log.warning("Failed to load config: %s", exc)
self.config = dict(self.default_config)
return dict(self.config)
def save_config(self, cfg: dict):
self.ensure_dirs()
self.config = dict(cfg)
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def reset_log_file(self):
if self.log_file.exists():
try:
self.log_file.unlink()
except Exception:
pass
def setup_logging(self, verbose: bool = False, log_max_mb: float = 5):
self.ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
for handler in list(root.handlers):
if getattr(handler, "_tg_ws_proxy_runtime_handler", False):
root.removeHandler(handler)
try:
handler.close()
except Exception:
pass
fh = logging.handlers.RotatingFileHandler(
str(self.log_file),
maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"))
fh._tg_ws_proxy_runtime_handler = True
root.addHandler(fh)
if not getattr(sys, "frozen", False):
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG if verbose else logging.INFO)
ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S"))
ch._tg_ws_proxy_runtime_handler = True
root.addHandler(ch)
def prepare(self) -> dict:
cfg = self.load_config()
self.save_config(cfg)
return cfg
def _emit_error(self, text: str):
if self.on_error:
self.on_error(text)
def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str],
host: str = "127.0.0.1"):
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
self._async_stop = (loop, stop_ev)
try:
loop.run_until_complete(self.run_proxy(stop_event=stop_ev))
except Exception as exc:
self.log.error("Proxy thread crashed: %s", exc)
exc_text = str(exc)
if ("10048" in exc_text or
"address already in use" in exc_text.lower()):
self._emit_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите.")
else:
self._emit_error(str(exc) or exc.__class__.__name__)
finally:
loop.close()
self._async_stop = None
def start_proxy(self, cfg: Optional[dict] = None) -> bool:
if self._proxy_thread and self._proxy_thread.is_alive():
self.log.info("Proxy already running")
return True
active_cfg = dict(cfg or self.config or self.default_config)
self.config = dict(active_cfg)
port = active_cfg.get("port", self.default_config["port"])
host = active_cfg.get("host", self.default_config["host"])
dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"])
buf_kb = active_cfg.get("buf_kb", self.default_config["buf_kb"])
pool_size = active_cfg.get(
"pool_size", self.default_config["pool_size"])
try:
dc_opt = self.parse_dc_ip_list(dc_ip_list)
except ValueError as exc:
self.log.error("Bad config dc_ip: %s", exc)
self._emit_error("Ошибка конфигурации:\n%s" % exc)
return False
tg_ws_proxy.proxy_config = self._build_core_config(active_cfg, dc_opt)
self.save_config(active_cfg)
self.log.info("Starting proxy on %s:%d ...", host, port)
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF
tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size)
self._proxy_thread = self.thread_factory(
target=self._run_proxy_thread,
args=(
port,
dc_opt,
host,
),
daemon=True,
name="proxy")
self._proxy_thread.start()
return True
def stop_proxy(self):
if self._async_stop:
loop, stop_ev = self._async_stop
loop.call_soon_threadsafe(stop_ev.set)
if self._proxy_thread:
self._proxy_thread.join(timeout=2)
self._proxy_thread = None
self.log.info("Proxy stopped")
def restart_proxy(self, delay_seconds: float = 0.3) -> bool:
self.log.info("Restarting proxy...")
self.stop_proxy()
time.sleep(delay_seconds)
return self.start_proxy()
def is_proxy_running(self) -> bool:
return bool(self._proxy_thread and self._proxy_thread.is_alive())
+208
View File
@@ -0,0 +1,208 @@
from __future__ import annotations
import os
from typing import Protocol
_SBOX = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B,
0xFE, 0xD7, 0xAB, 0x76, 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0,
0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, 0xB7, 0xFD, 0x93, 0x26,
0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2,
0xEB, 0x27, 0xB2, 0x75, 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0,
0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, 0x53, 0xD1, 0x00, 0xED,
0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F,
0x50, 0x3C, 0x9F, 0xA8, 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5,
0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, 0xCD, 0x0C, 0x13, 0xEC,
0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14,
0xDE, 0x5E, 0x0B, 0xDB, 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C,
0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, 0xE7, 0xC8, 0x37, 0x6D,
0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F,
0x4B, 0xBD, 0x8B, 0x8A, 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E,
0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, 0xE1, 0xF8, 0x98, 0x11,
0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F,
0xB0, 0x54, 0xBB, 0x16,
)
_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
class AesCtrTransform(Protocol):
def update(self, data: bytes) -> bytes:
...
def finalize(self) -> bytes:
...
def _xtime(value: int) -> int:
value <<= 1
if value & 0x100:
value ^= 0x11B
return value & 0xFF
def _mul2(value: int) -> int:
return _xtime(value)
def _mul3(value: int) -> int:
return _xtime(value) ^ value
def _add_round_key(state: list[int], round_key: bytes):
for idx in range(16):
state[idx] ^= round_key[idx]
def _sub_bytes(state: list[int]):
for idx in range(16):
state[idx] = _SBOX[state[idx]]
def _shift_rows(state: list[int]):
state[1], state[5], state[9], state[13] = (
state[5], state[9], state[13], state[1]
)
state[2], state[6], state[10], state[14] = (
state[10], state[14], state[2], state[6]
)
state[3], state[7], state[11], state[15] = (
state[15], state[3], state[7], state[11]
)
def _mix_columns(state: list[int]):
for offset in range(0, 16, 4):
s0, s1, s2, s3 = state[offset:offset + 4]
state[offset + 0] = _mul2(s0) ^ _mul3(s1) ^ s2 ^ s3
state[offset + 1] = s0 ^ _mul2(s1) ^ _mul3(s2) ^ s3
state[offset + 2] = s0 ^ s1 ^ _mul2(s2) ^ _mul3(s3)
state[offset + 3] = _mul3(s0) ^ s1 ^ s2 ^ _mul2(s3)
def _rot_word(word: list[int]) -> list[int]:
return word[1:] + word[:1]
def _sub_word(word: list[int]) -> list[int]:
return [_SBOX[value] for value in word]
def _expand_round_keys(key: bytes) -> tuple[list[bytes], int]:
if len(key) not in (16, 24, 32):
raise ValueError("AES key must be 16, 24, or 32 bytes long")
nk = len(key) // 4
nr = {4: 10, 6: 12, 8: 14}[nk]
words = [list(key[idx:idx + 4]) for idx in range(0, len(key), 4)]
total_words = 4 * (nr + 1)
for idx in range(nk, total_words):
temp = words[idx - 1][:]
if idx % nk == 0:
temp = _sub_word(_rot_word(temp))
temp[0] ^= _RCON[idx // nk - 1]
elif nk > 6 and idx % nk == 4:
temp = _sub_word(temp)
words.append([
words[idx - nk][byte_idx] ^ temp[byte_idx]
for byte_idx in range(4)
])
round_keys = []
for round_idx in range(nr + 1):
start = round_idx * 4
round_keys.append(bytes(sum(words[start:start + 4], [])))
return round_keys, nr
class _PurePythonAesCtrTransform:
def __init__(self, key: bytes, iv: bytes):
if len(iv) != 16:
raise ValueError("AES-CTR IV must be 16 bytes long")
self._round_keys, self._rounds = _expand_round_keys(key)
self._counter = bytearray(iv)
self._buffer = b""
self._buffer_offset = 0
def update(self, data: bytes) -> bytes:
if not data:
return b""
out = bytearray(len(data))
data_offset = 0
while data_offset < len(data):
if self._buffer_offset >= len(self._buffer):
self._buffer = self._encrypt_block(bytes(self._counter))
self._buffer_offset = 0
self._increment_counter()
available = len(self._buffer) - self._buffer_offset
chunk_size = min(len(data) - data_offset, available)
for chunk_idx in range(chunk_size):
out[data_offset + chunk_idx] = (
data[data_offset + chunk_idx]
^ self._buffer[self._buffer_offset + chunk_idx]
)
data_offset += chunk_size
self._buffer_offset += chunk_size
return bytes(out)
def finalize(self) -> bytes:
return b""
def _encrypt_block(self, block: bytes) -> bytes:
state = list(block)
_add_round_key(state, self._round_keys[0])
for round_idx in range(1, self._rounds):
_sub_bytes(state)
_shift_rows(state)
_mix_columns(state)
_add_round_key(state, self._round_keys[round_idx])
_sub_bytes(state)
_shift_rows(state)
_add_round_key(state, self._round_keys[self._rounds])
return bytes(state)
def _increment_counter(self):
for idx in range(15, -1, -1):
self._counter[idx] = (self._counter[idx] + 1) & 0xFF
if self._counter[idx] != 0:
break
def _create_cryptography_transform(key: bytes,
iv: bytes) -> AesCtrTransform:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
return cipher.encryptor()
def create_aes_ctr_transform(key: bytes, iv: bytes,
backend: str | None = None) -> AesCtrTransform:
"""
Create a stateful AES-CTR transform.
Windows keeps using `cryptography` by default. Android can select the
pure-Python backend to avoid native build dependencies.
"""
selected = backend or os.environ.get(
'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography')
if selected == 'cryptography':
return _create_cryptography_transform(key, iv)
if selected == 'python':
return _PurePythonAesCtrTransform(key, iv)
raise ValueError(f"Unsupported AES-CTR backend: {selected}")
+533 -541
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ keywords = [
"proxy",
"bypass",
"websocket",
"socks5",
"mtproto",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
+264
View File
@@ -0,0 +1,264 @@
import sys
import unittest
import json
import subprocess
from pathlib import Path
sys.path.insert(0, str(
Path(__file__).resolve().parents[1] / "android" / "app" / "src" / "main" / "python"
))
import android_proxy_bridge # noqa: E402
import proxy.tg_ws_proxy as tg_ws_proxy # noqa: E402
class FakeJavaArrayList:
def __init__(self, items):
self._items = list(items)
def size(self):
return len(self._items)
def get(self, index):
return self._items[index]
class AndroidProxyBridgeTests(unittest.TestCase):
def tearDown(self):
tg_ws_proxy.reset_stats()
android_proxy_bridge._LAST_ERROR = None
def test_normalize_dc_ip_list_with_python_iterable(self):
result = android_proxy_bridge._normalize_dc_ip_list([
"2:149.154.167.220",
" ",
"4:149.154.167.220 ",
])
self.assertEqual(result, [
"2:149.154.167.220",
"4:149.154.167.220",
])
def test_get_runtime_stats_json_reports_proxy_counters(self):
tg_ws_proxy.reset_stats()
snapshot = tg_ws_proxy.get_stats_snapshot()
snapshot["bytes_up"] = 1536
snapshot["bytes_down"] = 4096
tg_ws_proxy._stats.bytes_up = snapshot["bytes_up"]
tg_ws_proxy._stats.bytes_down = snapshot["bytes_down"]
result = json.loads(android_proxy_bridge.get_runtime_stats_json())
self.assertEqual(result["bytes_up"], 1536)
self.assertEqual(result["bytes_down"], 4096)
self.assertFalse(result["running"])
self.assertIsNone(result["last_error"])
def test_get_runtime_stats_json_includes_last_error(self):
android_proxy_bridge._LAST_ERROR = "boom"
result = json.loads(android_proxy_bridge.get_runtime_stats_json())
self.assertEqual(result["last_error"], "boom")
def test_normalize_dc_ip_list_with_java_array_list_shape(self):
result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([
"2:149.154.167.220",
"4:149.154.167.220",
]))
self.assertEqual(result, [
"2:149.154.167.220",
"4:149.154.167.220",
])
def test_start_proxy_saves_advanced_runtime_config(self):
captured = {}
class FakeRuntime:
def __init__(self, *args, **kwargs):
captured["runtime_init"] = kwargs
self.log_file = Path("/tmp/proxy.log")
def reset_log_file(self):
captured["reset_log_file"] = True
def setup_logging(self, verbose=False, log_max_mb=5):
captured["verbose"] = verbose
captured["log_max_mb"] = log_max_mb
def save_config(self, config):
captured["config"] = dict(config)
def start_proxy(self, config):
captured["start_proxy"] = dict(config)
return True
def is_proxy_running(self):
return True
def stop_proxy(self):
captured["stop_proxy"] = True
original_runtime = android_proxy_bridge.ProxyAppRuntime
try:
android_proxy_bridge.ProxyAppRuntime = FakeRuntime
log_path = android_proxy_bridge.start_proxy(
"/tmp/app",
"127.0.0.1",
1443,
"0123456789abcdef0123456789abcdef",
["2:149.154.167.220"],
7.0,
512,
6,
True,
)
finally:
android_proxy_bridge.ProxyAppRuntime = original_runtime
self.assertEqual(log_path, "/tmp/proxy.log")
self.assertEqual(captured["config"]["secret"], "0123456789abcdef0123456789abcdef")
self.assertEqual(captured["config"]["log_max_mb"], 7.0)
self.assertEqual(captured["config"]["buf_kb"], 512)
self.assertEqual(captured["config"]["pool_size"], 6)
self.assertEqual(captured["log_max_mb"], 7.0)
self.assertTrue(captured["verbose"])
def test_get_update_status_json_merges_python_update_state(self):
original_load_update_check = android_proxy_bridge._load_update_check
try:
captured = {}
class FakeUpdateCheck:
RELEASES_PAGE_URL = "https://example.com/releases/latest"
@staticmethod
def run_check(version):
captured["run_check_version"] = version
@staticmethod
def get_status():
return {
"checked": True,
"latest": "1.3.1",
"has_update": True,
"ahead_of_release": False,
"html_url": "https://example.com/release",
"error": "",
}
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
result = json.loads(android_proxy_bridge.get_update_status_json(True))
finally:
android_proxy_bridge._load_update_check = original_load_update_check
self.assertEqual(captured["run_check_version"], android_proxy_bridge.__version__)
self.assertEqual(result["current_version"], android_proxy_bridge.__version__)
self.assertEqual(result["latest"], "1.3.1")
self.assertTrue(result["has_update"])
self.assertTrue(result["checked"])
self.assertEqual(result["html_url"], "https://example.com/release")
def test_get_update_status_json_reports_unchecked_state(self):
original_load_update_check = android_proxy_bridge._load_update_check
try:
class FakeUpdateCheck:
RELEASES_PAGE_URL = "https://example.com/releases/latest"
@staticmethod
def get_status():
return {
"checked": False,
"latest": "",
"has_update": False,
"ahead_of_release": False,
"html_url": "",
"error": "",
}
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
result = json.loads(android_proxy_bridge.get_update_status_json(False))
finally:
android_proxy_bridge._load_update_check = original_load_update_check
self.assertFalse(result["checked"])
self.assertEqual(result["current_version"], android_proxy_bridge.__version__)
def test_get_update_status_json_reports_import_error_without_breaking_bridge(self):
original_load_update_check = android_proxy_bridge._load_update_check
try:
def fail():
raise ModuleNotFoundError("No module named 'utils'")
android_proxy_bridge._load_update_check = fail
result = json.loads(android_proxy_bridge.get_update_status_json(True))
finally:
android_proxy_bridge._load_update_check = original_load_update_check
self.assertFalse(result["checked"])
self.assertIn("No module named 'utils'", result["error"])
def test_get_update_status_json_normalizes_none_fields_for_kotlin(self):
original_load_update_check = android_proxy_bridge._load_update_check
try:
class FakeUpdateCheck:
RELEASES_PAGE_URL = "https://example.com/releases/latest"
@staticmethod
def get_status():
return {
"checked": True,
"latest": None,
"has_update": False,
"ahead_of_release": True,
"html_url": None,
"error": None,
}
android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck
result = json.loads(android_proxy_bridge.get_update_status_json(False))
finally:
android_proxy_bridge._load_update_check = original_load_update_check
self.assertEqual(result["latest"], "")
self.assertEqual(result["error"], "")
self.assertEqual(result["html_url"], "https://example.com/releases/latest")
def test_android_bridge_import_and_update_status_work_without_cryptography(self):
root = Path(__file__).resolve().parents[1]
script = f"""
import importlib.abc
import sys
from pathlib import Path
root = Path({str(root)!r})
sys.path.insert(0, str(root / "android" / "app" / "src" / "main" / "python"))
sys.path.insert(0, str(root))
class BlockCryptography(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname == "cryptography" or fullname.startswith("cryptography."):
raise ModuleNotFoundError("No module named 'cryptography'")
return None
sys.meta_path.insert(0, BlockCryptography())
import android_proxy_bridge
print(android_proxy_bridge.get_update_status_json(False))
"""
result = subprocess.run(
[sys.executable, "-c", script],
check=True,
capture_output=True,
text=True,
)
payload = json.loads(result.stdout.strip())
self.assertEqual(payload["current_version"], android_proxy_bridge.__version__)
self.assertEqual(payload["error"], "")
if __name__ == "__main__":
unittest.main()
+166
View File
@@ -0,0 +1,166 @@
import json
import tempfile
import unittest
from pathlib import Path
from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime
class _FakeThread:
def __init__(self, target=None, args=(), daemon=None, name=None):
self.target = target
self.args = args
self.daemon = daemon
self.name = name
self.started = False
self.join_timeout = None
self._alive = False
def start(self):
self.started = True
self._alive = True
def is_alive(self):
return self._alive
def join(self, timeout=None):
self.join_timeout = timeout
self._alive = False
class ProxyAppRuntimeTests(unittest.TestCase):
def test_load_config_returns_defaults_when_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
runtime = ProxyAppRuntime(Path(tmpdir))
cfg = runtime.load_config()
self.assertEqual(cfg, DEFAULT_CONFIG)
def test_load_config_merges_defaults_into_saved_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
app_dir = Path(tmpdir)
config_path = app_dir / "config.json"
app_dir.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps({"port": 9050, "host": "127.0.0.2"}),
encoding="utf-8")
runtime = ProxyAppRuntime(app_dir)
cfg = runtime.load_config()
self.assertEqual(cfg["port"], 9050)
self.assertEqual(cfg["host"], "127.0.0.2")
self.assertEqual(cfg["dc_ip"], DEFAULT_CONFIG["dc_ip"])
self.assertEqual(cfg["verbose"], DEFAULT_CONFIG["verbose"])
def test_invalid_config_file_falls_back_to_defaults(self):
with tempfile.TemporaryDirectory() as tmpdir:
app_dir = Path(tmpdir)
app_dir.mkdir(parents=True, exist_ok=True)
(app_dir / "config.json").write_text("{broken", encoding="utf-8")
runtime = ProxyAppRuntime(app_dir)
cfg = runtime.load_config()
self.assertEqual(cfg, DEFAULT_CONFIG)
def test_start_proxy_starts_thread_with_parsed_dc_options(self):
with tempfile.TemporaryDirectory() as tmpdir:
captured = {}
thread_holder = {}
def fake_parse(entries):
captured["dc_ip"] = list(entries)
return {2: "149.154.167.220"}
def fake_thread_factory(**kwargs):
thread = _FakeThread(**kwargs)
thread_holder["thread"] = thread
return thread
runtime = ProxyAppRuntime(
Path(tmpdir),
parse_dc_ip_list=fake_parse,
thread_factory=fake_thread_factory)
started = runtime.start_proxy(dict(DEFAULT_CONFIG))
self.assertTrue(started)
self.assertEqual(captured["dc_ip"], DEFAULT_CONFIG["dc_ip"])
self.assertTrue(thread_holder["thread"].started)
self.assertEqual(
thread_holder["thread"].args,
(DEFAULT_CONFIG["port"], {2: "149.154.167.220"},
DEFAULT_CONFIG["host"]))
def test_start_proxy_reports_bad_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
def fake_parse(entries):
raise ValueError("bad dc mapping")
runtime = ProxyAppRuntime(
Path(tmpdir),
parse_dc_ip_list=fake_parse,
on_error=errors.append)
started = runtime.start_proxy({
"host": "127.0.0.1",
"port": 1080,
"dc_ip": ["broken"],
"verbose": False,
})
self.assertFalse(started)
self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"])
def test_run_proxy_thread_reports_generic_runtime_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
async def fake_run_proxy(stop_event=None):
raise RuntimeError("proxy boom")
runtime = ProxyAppRuntime(
Path(tmpdir),
on_error=errors.append,
run_proxy=fake_run_proxy,
)
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
self.assertEqual(errors, ["proxy boom"])
def test_run_proxy_thread_reports_port_in_use_case_insensitively(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
async def fake_run_proxy(stop_event=None):
raise RuntimeError(
"[Errno 98] error while attempting to bind on address "
"('127.0.0.1', 1443): address already in use"
)
runtime = ProxyAppRuntime(
Path(tmpdir),
on_error=errors.append,
run_proxy=fake_run_proxy,
)
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
self.assertEqual(
errors,
[
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
],
)
if __name__ == "__main__":
unittest.main()
+142
View File
@@ -0,0 +1,142 @@
import hashlib
import struct
import unittest
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from proxy.crypto_backend import create_aes_ctr_transform
from proxy.tg_ws_proxy import (
PROTO_ABRIDGED_INT,
PROTO_TAG_ABRIDGED,
_MsgSplitter,
_generate_relay_init,
_try_handshake,
)
KEY = bytes(range(32))
IV = bytes(range(16))
SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
def _xor(left: bytes, right: bytes) -> bytes:
return bytes(a ^ b for a, b in zip(left, right))
def _keystream(size: int, key: bytes, iv: bytes) -> bytes:
transform = Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor()
return transform.update(b"\x00" * size)
def _build_client_handshake(
dc_raw: int,
proto_tag: bytes = PROTO_TAG_ABRIDGED,
secret: bytes = SECRET,
) -> bytes:
packet = bytearray(64)
packet[8:40] = KEY
packet[40:56] = IV
dec_key = hashlib.sha256(KEY + secret).digest()
plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
packet[56:64] = _xor(plain_tail, _keystream(64, dec_key, IV)[56:64])
return bytes(packet)
def _encrypt_after_init(relay_init: bytes, plaintext: bytes) -> bytes:
transform = Cipher(
algorithms.AES(relay_init[8:40]),
modes.CTR(relay_init[40:56]),
).encryptor()
transform.update(b"\x00" * 64)
return transform.update(plaintext)
class CryptoBackendTests(unittest.TestCase):
def test_python_backend_matches_cryptography_stream(self):
cryptography_transform = create_aes_ctr_transform(
KEY, IV, backend="cryptography")
python_transform = create_aes_ctr_transform(KEY, IV, backend="python")
chunks = [
b"",
b"\x00" * 16,
bytes(range(31)),
b"telegram-proxy",
b"\xff" * 64,
]
cryptography_out = b"".join(
cryptography_transform.update(chunk) for chunk in chunks
) + cryptography_transform.finalize()
python_out = b"".join(
python_transform.update(chunk) for chunk in chunks
) + python_transform.finalize()
self.assertEqual(python_out, cryptography_out)
def test_unknown_backend_raises_error(self):
with self.assertRaises(ValueError):
create_aes_ctr_transform(KEY, IV, backend="missing")
class MtProtoHandshakeTests(unittest.TestCase):
def test_try_handshake_reads_non_media_dc(self):
handshake = _build_client_handshake(dc_raw=2)
result = _try_handshake(handshake, SECRET)
self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
def test_try_handshake_reads_media_dc(self):
handshake = _build_client_handshake(dc_raw=-4)
result = _try_handshake(handshake, SECRET)
self.assertEqual(result[:3], (4, True, PROTO_TAG_ABRIDGED))
def test_try_handshake_rejects_wrong_secret(self):
handshake = _build_client_handshake(dc_raw=2)
result = _try_handshake(
handshake,
bytes.fromhex("fedcba9876543210fedcba9876543210"),
)
self.assertIsNone(result)
def test_generate_relay_init_encodes_proto_and_signed_dc(self):
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -3)
decryptor = Cipher(
algorithms.AES(relay_init[8:40]),
modes.CTR(relay_init[40:56]),
).encryptor()
decrypted = decryptor.update(relay_init)
self.assertEqual(decrypted[56:60], PROTO_TAG_ABRIDGED)
self.assertEqual(struct.unpack("<h", decrypted[60:62])[0], -3)
class MsgSplitterTests(unittest.TestCase):
def test_splitter_splits_multiple_abridged_messages(self):
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
plain_chunk = b"\x01abcd\x02EFGH1234"
encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]])
def test_splitter_leaves_single_message_intact(self):
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, 2)
plain_chunk = b"\x02abcdefgh"
encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
self.assertEqual(parts, [encrypted_chunk])
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import hashlib
import struct
import unittest
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from proxy.tg_ws_proxy import (
PROTO_TAG_ABRIDGED,
PROTO_TAG_INTERMEDIATE,
_generate_relay_init,
_try_handshake,
)
KEY = bytes(range(32))
IV = bytes(range(16))
SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
def _xor(left: bytes, right: bytes) -> bytes:
return bytes(a ^ b for a, b in zip(left, right))
def _build_client_handshake(dc_raw: int, proto_tag: bytes) -> bytes:
packet = bytearray(64)
packet[8:40] = KEY
packet[40:56] = IV
dec_key = hashlib.sha256(KEY + SECRET).digest()
decryptor = Cipher(algorithms.AES(dec_key), modes.CTR(IV)).encryptor()
keystream = decryptor.update(b"\x00" * 64)
plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
packet[56:64] = _xor(plain_tail, keystream[56:64])
return bytes(packet)
class MtProtoProtocolTests(unittest.TestCase):
def test_try_handshake_accepts_abridged_proto(self):
handshake = _build_client_handshake(2, PROTO_TAG_ABRIDGED)
result = _try_handshake(handshake, SECRET)
self.assertIsNotNone(result)
self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
def test_try_handshake_accepts_intermediate_proto(self):
handshake = _build_client_handshake(-4, PROTO_TAG_INTERMEDIATE)
result = _try_handshake(handshake, SECRET)
self.assertIsNotNone(result)
self.assertEqual(result[:3], (4, True, PROTO_TAG_INTERMEDIATE))
def test_generate_relay_init_produces_handshake_sized_packet(self):
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
self.assertEqual(len(relay_init), 64)
self.assertEqual(relay_init[0], relay_init[0] & 0xFF)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
import unittest
from proxy import __version__
from utils import update_check
class UpdateCheckTests(unittest.TestCase):
def setUp(self):
self._orig_state = dict(update_check._state)
def tearDown(self):
update_check._state.clear()
update_check._state.update(self._orig_state)
def test_apply_release_tag_marks_update_available(self):
version_parts = [int(part) for part in __version__.split(".")]
version_parts[-1] += 1
next_version = ".".join(str(part) for part in version_parts)
update_check._apply_release_tag(
tag=f"v{next_version}",
html_url="https://example.com/release",
current_version=__version__,
)
status = update_check.get_status()
self.assertTrue(status["has_update"])
self.assertFalse(status["ahead_of_release"])
self.assertEqual(status["latest"], next_version)
self.assertEqual(status["html_url"], "https://example.com/release")
def test_apply_release_tag_marks_ahead_of_release(self):
update_check._apply_release_tag(
tag="v1.2.1",
html_url="https://example.com/release",
current_version=__version__,
)
status = update_check.get_status()
self.assertFalse(status["has_update"])
self.assertTrue(status["ahead_of_release"])
self.assertEqual(status["latest"], "1.2.1")
def test_apply_release_tag_marks_latest_when_versions_match(self):
update_check._apply_release_tag(
tag=f"v{__version__}",
html_url="https://example.com/release",
current_version=__version__,
)
status = update_check.get_status()
self.assertFalse(status["has_update"])
self.assertFalse(status["ahead_of_release"])
self.assertEqual(status["latest"], __version__)
if __name__ == "__main__":
unittest.main()
+30 -34
View File
@@ -1,8 +1,3 @@
"""
Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux).
Цвета и отступы задаются в одном месте — правки темы не дублируются по платформам.
"""
from __future__ import annotations
import sys
@@ -13,11 +8,7 @@ from typing import Any, Callable, Optional, Tuple
_tk_variable_del_guard_installed = False
def _install_tkinter_variable_del_guard() -> None:
"""
Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё
вызывает Variable.__del__ (StringVar и т.д.) — напр. окно CTk в фоновом потоке.
"""
def install_tkinter_variable_del_guard() -> None:
global _tk_variable_del_guard_installed
if _tk_variable_del_guard_installed:
return
@@ -32,24 +23,24 @@ def _install_tkinter_variable_del_guard() -> None:
tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment]
_tk_variable_del_guard_installed = True
# Размеры и отступы (единые для диалогов настроек и первого запуска)
CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560)
CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14)
FIRST_RUN_SIZE: Tuple[int, int] = (520, 440)
FIRST_RUN_SIZE: Tuple[int, int] = (520, 480)
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
@dataclass(frozen=True)
class CtkTheme:
"""Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста."""
tg_blue: tuple = ("#3390ec", "#3390ec")
tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
bg: tuple = ("#ffffff", "#1e1e1e")
field_bg: tuple = ("#f0f2f5", "#2b2b2b")
field_border: tuple = ("#d6d9dc", "#3a3a3a")
text_primary: tuple = ("#000000", "#ffffff")
text_secondary: tuple = ("#707579", "#aaaaaa")
tg_blue: str = "#3390ec"
tg_blue_hover: str = "#2b7cd4"
bg: str = "#ffffff"
field_bg: str = "#f0f2f5"
field_border: str = "#d6d9dc"
text_primary: str = "#000000"
text_secondary: str = "#707579"
ui_font_family: str = "Sans"
mono_font_family: str = "Monospace"
@@ -61,17 +52,16 @@ def ctk_theme_for_platform() -> CtkTheme:
def apply_ctk_appearance(ctk: Any) -> None:
ctk.set_appearance_mode("light")
ctk.set_appearance_mode("auto")
ctk.set_default_color_theme("blue")
def center_ctk_geometry(root: Any, width: int, height: int) -> None:
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}")
def create_ctk_root(
def create_ctk_toplevel(
ctk: Any,
*,
title: str,
@@ -81,21 +71,27 @@ def create_ctk_root(
topmost: bool = True,
after_create: Optional[Callable[[Any], None]] = None,
) -> Any:
"""
Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры.
after_create — опционально: установка иконки окна (различается по ОС).
"""
_install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
root = ctk.CTk()
root = ctk.CTkToplevel()
root.title(title)
root.resizable(False, False)
if topmost:
root.attributes("-topmost", True)
center_ctk_geometry(root, width, height)
root.configure(fg_color=theme.bg)
if topmost:
root.attributes("-topmost", True)
root.lift()
root.focus_force()
if after_create:
after_create(root)
_after_id = root.after(300, lambda: after_create(root))
_orig_destroy = root.destroy
def _safe_destroy():
try:
root.after_cancel(_after_id)
except Exception:
pass
_orig_destroy()
root.destroy = _safe_destroy
return root
@@ -109,4 +105,4 @@ def main_content_frame(
) -> Any:
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
frame.pack(fill="both", expand=True, padx=padx, pady=pady)
return frame
return frame
+3 -8
View File
@@ -1,7 +1,3 @@
"""
Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap.
"""
from __future__ import annotations
import tkinter as tk
@@ -9,8 +5,6 @@ from typing import Any, List, Optional
class CtkTooltip:
"""Показ текста при наведении на виджет."""
def __init__(
self,
widget: Any,
@@ -31,6 +25,8 @@ class CtkTooltip:
widget.bind("<Destroy>", self._on_destroy, add="+")
def _schedule(self, _event: Any = None) -> None:
if self.widget is None:
return
self._cancel_after()
self._after_id = self.widget.after(self.delay_ms, self._show)
@@ -89,6 +85,7 @@ class CtkTooltip:
def _on_destroy(self, _event: Any = None) -> None:
self._hide()
self.widget = None
def _is_windows() -> bool:
@@ -104,11 +101,9 @@ def attach_ctk_tooltip(
delay_ms: int = 450,
wraplength: int = 320,
) -> None:
"""Повесить подсказку на виджет (CTk или tk)."""
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
"""Одна и та же подсказка на несколько виджетов (подпись + поле)."""
for w in widgets:
attach_ctk_tooltip(w, text, **kwargs)
+167 -230
View File
@@ -1,10 +1,6 @@
"""
Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск.
Логика сохранения и колбэки остаются в платформенных модулях.
"""
from __future__ import annotations
import os
import webbrowser
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -20,15 +16,15 @@ from ui.ctk_theme import (
)
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
# Подсказки для формы настроек (новые пользователи)
_TIP_HOST = (
"Адрес, на котором прокси принимает SOCKS5-подключения.\n"
"Адрес, на котором прокси принимает подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
)
_TIP_PORT = (
"Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть "
"Порт прокси. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт"
)
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
@@ -51,16 +47,62 @@ _TIP_LOG_MB = (
)
_TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуска сбросится"
)
_TIP_CHECK_UPDATES = (
"При запуске проверять наличие обновлений"
"Если вы переместите программу в другую папку, автозапуск сбросится"
)
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE)
_CONFIG_FORM_INNER_WIDTH = 396
_INNER_W = 396
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def _checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = _label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(value=str(value))
ent = _entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def tray_settings_scroll_and_footer(
@@ -68,10 +110,6 @@ def tray_settings_scroll_and_footer(
content_parent: Any,
theme: CtkTheme,
) -> Tuple[Any, Any]:
"""
Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки).
Возвращает (scroll_frame, footer_frame).
"""
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
footer.pack(side="bottom", fill="x")
scroll = ctk.CTkScrollableFrame(
@@ -93,22 +131,12 @@ def _config_section(
*,
bottom_spacer: int = 6,
) -> Any:
"""Заголовок секции и карточка с рамкой для группировки полей."""
wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer))
ctk.CTkLabel(
wrap,
text=title,
font=(theme.ui_font_family, 12, "bold"),
text_color=theme.text_primary,
anchor="w",
).pack(anchor="w", pady=(0, 2))
_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame(
wrap,
fg_color=theme.field_bg,
corner_radius=10,
border_width=1,
border_color=theme.field_border,
wrap, fg_color=theme.field_bg, corner_radius=10,
border_width=1, border_color=theme.field_border,
)
card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent")
@@ -120,6 +148,7 @@ def _config_section(
class TrayConfigFormWidgets:
host_var: Any
port_var: Any
secret_var: Any
dc_textbox: Any
verbose_var: Any
adv_entries: List[Any]
@@ -138,102 +167,67 @@ def install_tray_config_form(
show_autostart: bool = False,
autostart_value: bool = False,
) -> TrayConfigFormWidgets:
"""Поля настроек прокси внутри уже созданного `frame`."""
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",
text_color=theme.text_primary, anchor="w",
).pack(side="left")
ctk.CTkLabel(
header,
text=f"v{__version__}",
header, text=f"v{__version__}",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="e",
text_color=theme.text_secondary, anchor="e",
).pack(side="right")
inner_w = _CONFIG_FORM_INNER_WIDTH
conn = _config_section(ctk, frame, theme, "Подключение SOCKS5")
conn = _config_section(ctk, frame, theme, "Подключение MTProto")
host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x")
host_col = ctk.CTkFrame(host_row, fg_color="transparent")
host_col, host_var = _labeled_entry(
ctk, host_row, theme, "IP-адрес",
cfg.get("host", default_config["host"]),
tip=_TIP_HOST, width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
host_lbl = ctk.CTkLabel(
host_col,
text="IP-адрес",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
)
host_lbl.pack(anchor="w", pady=(0, 2))
host_var = ctk.StringVar(value=cfg.get("host", default_config["host"]))
host_entry = ctk.CTkEntry(
host_col,
textvariable=host_var,
width=160,
height=36,
font=(theme.ui_font_family, 13),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
host_entry.pack(fill="x", pady=(0, 0))
attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST)
port_col = ctk.CTkFrame(host_row, fg_color="transparent")
port_col, port_var = _labeled_entry(
ctk, host_row, theme, "Порт",
cfg.get("port", default_config["port"]),
tip=_TIP_PORT, width=100,
)
port_col.pack(side="left")
port_lbl = ctk.CTkLabel(
port_col,
text="Порт",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
secret_row = ctk.CTkFrame(conn, fg_color="transparent")
secret_row.pack(fill="x")
secret_col, secret_var = _labeled_entry(
ctk, secret_row, theme, "Secret",
cfg.get("secret", default_config["secret"]),
tip=_TIP_SECRET, width=160, pack_fill=True,
)
port_lbl.pack(anchor="w", pady=(0, 2))
port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"])))
port_entry = ctk.CTkEntry(
port_col,
textvariable=port_var,
width=100,
height=36,
font=(theme.ui_font_family, 13),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
port_entry.pack(anchor="w")
attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT)
secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
regen_col.pack(side="left", anchor="s")
ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
ctk.CTkButton(
regen_col, text="", width=36, height=36,
font=(theme.ui_font_family, 18), corner_radius=10,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff", border_width=1, border_color=theme.field_border,
command=lambda: secret_var.set(os.urandom(16).hex()),
).pack()
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
dc_lbl = ctk.CTkLabel(
dc_inner,
text="По одному правилу на строку, формат: номер:IP",
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox(
dc_inner,
width=inner_w,
height=88,
font=(theme.mono_font_family, 12),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
dc_inner, width=_INNER_W, height=88,
font=(theme.mono_font_family, 12), corner_radius=10,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
@@ -242,18 +236,7 @@ def install_tray_config_form(
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = ctk.CTkCheckBox(
log_inner,
text="Подробное логирование (verbose)",
variable=verbose_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
@@ -265,33 +248,17 @@ def install_tray_config_form(
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
]
for lbl, key, tip in adv_rows:
col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent")
col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = ctk.CTkLabel(
col_frame,
text=lbl,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
for label_text, key, tip in adv_rows:
col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = _label(ctk, col, theme, label_text, size=11)
adv_l.pack(anchor="w", pady=(0, 2))
adv_e = ctk.CTkEntry(
col_frame,
width=inner_w,
height=32,
font=(theme.ui_font_family, 13),
corner_radius=8,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
textvariable=ctk.StringVar(
value=str(cfg.get(key, default_config[key]))
),
adv_e = _entry(
ctk, col, theme, width=_INNER_W, height=32, radius=8,
textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
)
adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip)
attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb")
@@ -299,22 +266,9 @@ def install_tray_config_form(
upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status()
check_updates_var = ctk.BooleanVar(
value=bool(
cfg.get("check_updates", default_config.get("check_updates", True))
)
)
upd_cb = ctk.CTkCheckBox(
upd_inner,
text="Проверять обновления при запуске",
variable=check_updates_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
)
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
@@ -335,72 +289,38 @@ def install_tray_config_form(
else:
upd_status = "Установлена последняя известная версия с GitHub."
ctk.CTkLabel(
upd_inner,
text=upd_status,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
).pack(anchor="w", pady=(0, 8))
_label(ctk, upd_inner, theme, upd_status, size=11,
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
open_rel_btn = ctk.CTkButton(
upd_inner,
text="Открыть страницу релиза",
height=32,
font=(theme.ui_font_family, 13),
corner_radius=8,
fg_color=theme.field_bg,
hover_color=theme.field_border,
text_color=theme.text_primary,
border_width=1,
ctk.CTkButton(
upd_inner, text="Открыть страницу релиза", height=32,
font=(theme.ui_font_family, 13), corner_radius=8,
fg_color=theme.field_bg, hover_color=theme.field_border,
text_color=theme.text_primary, border_width=1,
border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u),
)
open_rel_btn.pack(anchor="w")
).pack(anchor="w")
autostart_var = None
if show_autostart:
sys_inner = _config_section(
ctk, frame, theme, "Запуск Windows", bottom_spacer=4
)
sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = ctk.CTkCheckBox(
sys_inner,
text="Автозапуск при включении компьютера",
variable=autostart_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
as_cb.pack(anchor="w", pady=(0, 4))
as_hint = ctk.CTkLabel(
sys_inner,
text="Если переместить программу в другую папку, запись автозапуска может сброситься.",
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
as_hint = _label(
ctk, sys_inner, theme,
"Если переместить программу в другую папку, запись автозапуска может сброситься.",
size=11, justify="left", wraplength=_INNER_W,
)
as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets(
host_var=host_var,
port_var=port_var,
dc_textbox=dc_textbox,
verbose_var=verbose_var,
adv_entries=adv_entries,
adv_keys=adv_keys,
autostart_var=autostart_var,
check_updates_var=check_updates_var,
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var,
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
)
@@ -409,7 +329,6 @@ def merge_adv_from_form(
base: Dict[str, Any],
default_config: dict,
) -> None:
"""Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place)."""
for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1]
@@ -428,9 +347,6 @@ def validate_config_form(
*,
include_autostart: bool,
) -> Union[dict, str]:
"""
Возвращает словарь полей конфига или строку ошибки для показа пользователю.
"""
import socket as _sock
host_val = widgets.host_var.get().strip()
@@ -456,9 +372,18 @@ def validate_config_form(
except ValueError as e:
return str(e)
secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32:
return "Secret должен содержать ровно 32 hex-символа (16 байт)."
try:
bytes.fromhex(secret_val)
except ValueError:
return "Secret должен состоять только из hex-символов (0-9, a-f)."
new_cfg: Dict[str, Any] = {
"host": host_val,
"port": port_val,
"secret": secret_val,
"dc_ip": lines,
"verbose": widgets.verbose_var.get(),
}
@@ -517,12 +442,11 @@ def populate_first_run_window(
*,
host: str,
port: int,
secret: str,
on_done: Callable[[bool], None],
) -> None:
"""
Содержимое окна первого запуска. on_done(open_in_telegram) — по «Начать» и по закрытию окна.
"""
tg_url = f"tg://socks?server={host}&port={port}"
link_host = tg_ws_proxy.get_link_host(host)
tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -541,18 +465,35 @@ def populate_first_run_window(
("Как подключить Telegram Desktop:", True),
(" Автоматически:", True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False),
(f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False),
("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5{host} : {port} (без логина/пароля)", False),
(f" MTProto{link_host} : {port}", False),
(f" Secret: dd{secret}", False),
]
textbox = ctk.CTkTextbox(
frame,
font=(theme.ui_font_family, 13),
fg_color=theme.bg,
border_width=0,
text_color=theme.text_primary,
activate_scrollbars=False,
wrap="word",
height=275,
)
textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
textbox._textbox.configure(spacing1=1, spacing3=1)
for text, bold in sections:
weight = "bold" if bold else "normal"
ctk.CTkLabel(frame, text=text,
font=(theme.ui_font_family, 13, weight),
text_color=theme.text_primary,
anchor="w", justify="left").pack(anchor="w", pady=1)
if text.startswith("\n"):
textbox.insert("end", "\n")
text = text[1:]
if bold:
textbox.insert("end", text + "\n", "bold")
else:
textbox.insert("end", text + "\n")
textbox.configure(state="disabled")
textbox.pack(anchor="w", fill="x")
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
@@ -560,12 +501,8 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас",
variable=auto_var, font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2,
border_color=theme.field_border).pack(anchor="w", pady=(0, 16))
_checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас",
auto_var).pack(anchor="w", pady=(0, 16))
def on_ok():
on_done(auto_var.get())
+5 -2
View File
@@ -5,10 +5,11 @@
from __future__ import annotations
import sys
import os
from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080,
"port": 1443,
"host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
@@ -20,8 +21,10 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex()
if sys.platform == "win32":
cfg["autostart"] = False
return cfg
+460
View File
@@ -0,0 +1,460 @@
from __future__ import annotations
import asyncio
import json
import logging
import logging.handlers
import os
import socket as _socket
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
import psutil
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
log = logging.getLogger("tg-ws-tray")
APP_NAME = "TgWsProxy"
def _app_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
APP_DIR = _app_dir()
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
IS_FROZEN = bool(getattr(sys, "frozen", False))
def ensure_dirs() -> None:
APP_DIR.mkdir(parents=True, exist_ok=True)
# single-instance lock
_lock_file_path: Optional[Path] = None
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
try:
lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
return False
except Exception:
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:
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)
continue
meta: dict = {}
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
pass
try:
if _same_process(meta, psutil.Process(pid), script_hint):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
lock_file.write_text(
json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
encoding="utf-8",
)
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def release_lock() -> None:
global _lock_file_path
if _lock_file_path:
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
# config
def load_config() -> dict:
ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict) -> None:
ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# logging
_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
ensure_dirs()
level = logging.DEBUG if verbose else logging.INFO
root = logging.getLogger()
root.setLevel(level)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not IS_FROZEN:
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
root.addHandler(ch)
# icon
def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin], fill=color)
for path in _font_paths():
try:
font = ImageFont.truetype(path, size=int(size * 0.55))
break
except Exception:
continue
else:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
"T",
fill=(255, 255, 255, 255),
font=font,
)
return img
def _font_paths():
if sys.platform == "win32":
return ["arial.ttf"]
if sys.platform == "darwin":
return ["/System/Library/Fonts/Helvetica.ttc"]
return [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
]
def load_icon():
from PIL import Image
icon_path = Path(__file__).parents[1] / "icon.ico"
if icon_path.exists():
try:
return Image.open(str(icon_path))
except Exception:
pass
return make_icon_image(64)
# proxy lifecycle
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
global _async_stop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
stop_ev = asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc) or "10048" in str(exc):
on_port_busy(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
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)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
return False
pc = tg_ws_proxy.proxy_config
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
pc.dc_redirects = dc_redirects
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
return True
def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
global _proxy_thread
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
if not apply_proxy_config(cfg):
on_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
)
_proxy_thread.start()
def stop_proxy() -> None:
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=5)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy(cfg, on_error)
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)
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
_IPV6_WARNING = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз."
)
def _has_ipv6() -> bool:
try:
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
ensure_dirs()
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
return
IPV6_WARN_MARKER.touch()
threading.Thread(
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
daemon=True,
).start()
# update check
def maybe_notify_update(
cfg: dict,
is_exiting: Callable[[], bool],
ask_open: Callable[[str, str], bool],
) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
import webbrowser
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if ask_open(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
# ctk thread (windows / linux)
_ctk_root: Any = None
_ctk_root_ready = threading.Event()
def ensure_ctk_thread(ctk: Any) -> bool:
global _ctk_root
if ctk is None:
return False
if _ctk_root_ready.is_set():
return True
def _run():
global _ctk_root
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
_ctk_root = ctk.CTk()
_ctk_root.withdraw()
_ctk_root_ready.set()
_ctk_root.mainloop()
threading.Thread(target=_run, daemon=True, name="ctk-root").start()
_ctk_root_ready.wait(timeout=5.0)
return _ctk_root is not None
def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
if _ctk_root is None:
return
done = threading.Event()
def _invoke():
try:
build_fn(done)
except Exception:
log.exception("CTk dialog failed")
done.set()
_ctk_root.after(0, _invoke)
done.wait()
import gc
gc.collect()
def quit_ctk() -> None:
if _ctk_root is not None:
try:
_ctk_root.after(0, _ctk_root.quit)
except Exception:
pass
# common bootstrap
def bootstrap(cfg: dict) -> None:
save_config(cfg)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(
cfg.get("verbose", False),
log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s starting", __version__)
log.info("Config: %s", cfg)
log.info("Log file: %s", LOG_FILE)
+141 -20
View File
@@ -1,11 +1,18 @@
"""
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
"""
from __future__ import annotations
import json
import os
import sys
import time
from itertools import zip_longest
from typing import Any, Dict, Optional
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@@ -13,6 +20,9 @@ REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
_MIN_FETCH_INTERVAL_SEC = 3600.0
_state: Dict[str, Any] = {
"checked": False,
"has_update": False,
@@ -23,6 +33,39 @@ _state: Dict[str, Any] = {
}
def _cache_file() -> Optional[Path]:
try:
if sys.platform == "win32":
root = Path(os.environ.get("APPDATA", str(Path.home()))) / "TgWsProxy"
elif sys.platform == "darwin":
root = Path.home() / "Library/Application Support/TgWsProxy"
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
root = (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "TgWsProxy"
root.mkdir(parents=True, exist_ok=True)
return root / ".update_check_cache.json"
except OSError:
return None
def _load_cache(path: Optional[Path]) -> Dict[str, Any]:
if not path or not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None:
if not path:
return
try:
path.write_text(json.dumps(data), encoding="utf-8")
except OSError:
pass
def _parse_version_tuple(s: str) -> tuple:
s = (s or "").strip().lstrip("vV")
if not s:
@@ -52,18 +95,56 @@ def _version_gt(a: str, b: str) -> bool:
return False
def fetch_latest_release(timeout: float = 12.0) -> Optional[dict]:
def _apply_release_tag(
tag: str, html_url: str, current_version: str,
) -> None:
global _state
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
return
latest_clean = tag.lstrip("vV")
cur = (current_version or "").strip().lstrip("vV")
_state["latest"] = latest_clean
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
_state["has_update"] = _version_gt(latest_clean, cur)
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
cur, latest_clean
)
def fetch_latest_release(
timeout: float = 12.0,
etag: Optional[str] = None,
) -> Tuple[Optional[dict], Optional[str], int]:
"""
GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код).
"""
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
}
if etag:
headers["If-None-Match"] = etag
req = Request(
RELEASES_LATEST_API,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check",
},
headers=headers,
method="GET",
)
with urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
try:
with urlopen(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode()
new_etag = resp.headers.get("ETag")
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), new_etag, int(code)
except HTTPError as e:
if e.code == 304:
hdrs = e.headers
new_etag = hdrs.get("ETag") if hdrs else None
return None, new_etag or etag, 304
raise
def run_check(current_version: str) -> None:
@@ -71,8 +152,41 @@ def run_check(current_version: str) -> None:
global _state
_state["checked"] = True
_state["error"] = None
cache_path = _cache_file()
cache = _load_cache(cache_path)
now = time.time()
last_attempt = float(cache.get("last_attempt_at") or 0)
if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC:
tag = (cache.get("tag_name") or "").strip()
if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
return
err = cache.get("last_error")
_state["error"] = (
err if err else "Проверка обновлений отложена (интервал между запросами)."
)
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = RELEASES_PAGE_URL
return
etag = (cache.get("etag") or "").strip() or None
try:
data = fetch_latest_release()
data, new_etag, code = fetch_latest_release(etag=etag)
cache["last_attempt_at"] = now
if code == 304:
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)
if new_etag:
cache["etag"] = new_etag
_save_cache(cache_path, cache)
return
assert data is not None
tag = (data.get("tag_name") or "").strip()
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
if not tag:
@@ -80,17 +194,24 @@ def run_check(current_version: str) -> None:
_state["ahead_of_release"] = False
_state["latest"] = None
_state["html_url"] = html_url
return
latest_clean = tag.lstrip("vV")
cur = (current_version or "").strip().lstrip("vV")
_state["latest"] = latest_clean
_state["html_url"] = html_url
_state["has_update"] = _version_gt(latest_clean, cur)
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
cur, latest_clean
)
else:
_apply_release_tag(tag, html_url, current_version)
if new_etag:
cache["etag"] = new_etag
cache["tag_name"] = tag
cache["html_url"] = html_url
cache.pop("last_error", None)
_save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
_state["error"] = str(e)
cache["last_attempt_at"] = now
msg = str(e)
if isinstance(e, HTTPError) and e.code == 403:
msg = (
"GitHub API вернул 403 (лимит или доступ). Повторите позже."
)
cache["last_error"] = msg
_save_cache(cache_path, cache)
_state["error"] = msg
_state["has_update"] = False
_state["ahead_of_release"] = False
_state["latest"] = None
+182 -602
View File
@@ -1,20 +1,14 @@
from __future__ import annotations
import ctypes
import ipaddress
import json
import logging
import logging.handlers
import os
import winreg
import psutil
import sys
import threading
import time
import webbrowser
import asyncio as _asyncio
import winreg
from pathlib import Path
from typing import Dict, Optional
from typing import Optional
try:
import pyperclip
@@ -32,196 +26,62 @@ except ImportError:
ctk = None
try:
from PIL import Image, ImageDraw, ImageFont
from PIL import Image
except ImportError:
Image = ImageDraw = ImageFont = None
Image = None
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
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,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons,
install_tray_config_form,
populate_first_run_window,
tray_settings_scroll_and_footer,
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
validate_config_form,
)
from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD,
CONFIG_DIALOG_SIZE,
FIRST_RUN_SIZE,
create_ctk_root,
ctk_theme_for_platform,
main_content_frame,
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
)
IS_FROZEN = bool(getattr(sys, "frozen", False))
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = default_tray_config()
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_lock_file_path: Optional[Path] = None
_exiting = False
log = logging.getLogger("tg-ws-tray")
ICON_PATH = str(Path(__file__).parent / "icon.ico")
_user32 = ctypes.windll.user32
_user32.MessageBoxW.argtypes = [
ctypes.c_void_p,
ctypes.c_wchar_p,
ctypes.c_wchar_p,
ctypes.c_uint,
]
_user32.MessageBoxW.restype = ctypes.c_int
# win32 dialogs
_u32 = ctypes.windll.user32
_u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint]
_u32.MessageBoxW.restype = ctypes.c_int
_MB_OK_ERR = 0x10
_MB_OK_INFO = 0x40
_MB_YESNO_Q = 0x24
_IDYES = 6
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
for arg in proc.cmdline():
if "windows.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return (
os.path.basename(sys.executable).lower() == proc.name().lower()
)
return False
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_ERR)
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
_u32.MessageBoxW(None, text, title, _MB_OK_INFO)
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
def _ensure_dirs():
APP_DIR.mkdir(parents=True, exist_ok=True)
# autostart (registry)
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict):
_ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_ensure_dirs()
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
fh = logging.handlers.RotatingFileHandler(
str(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"))
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"))
root.addHandler(ch)
def _autostart_reg_name() -> str:
return APP_NAME
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def _supports_autostart() -> bool:
@@ -234,472 +94,213 @@ def _autostart_command() -> str:
def is_autostart_enabled() -> bool:
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
0,
winreg.KEY_READ,
) as k:
val, _ = winreg.QueryValueEx(k, _autostart_reg_name())
stored = str(val).strip()
expected = _autostart_command().strip()
return stored == expected
except FileNotFoundError:
return False
except OSError:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ) as k:
val, _ = winreg.QueryValueEx(k, APP_NAME)
return str(val).strip() == _autostart_command().strip()
except (FileNotFoundError, OSError):
return False
def set_autostart_enabled(enabled: bool) -> None:
try:
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
) as k:
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as k:
if enabled:
winreg.SetValueEx(
k,
_autostart_reg_name(),
0,
winreg.REG_SZ,
_autostart_command(),
)
winreg.SetValueEx(k, APP_NAME, 0, winreg.REG_SZ, _autostart_command())
else:
try:
winreg.DeleteValue(k, _autostart_reg_name())
winreg.DeleteValue(k, APP_NAME)
except FileNotFoundError:
pass
except OSError as exc:
log.error("Failed to update autostart: %s", exc)
_show_error(
"Не удалось изменить автозапуск.\n\n"
"Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n"
f"Ошибка: {exc}"
"Попробуйте запустить приложение от имени пользователя "
f"с правами на реестр.\n\nОшибка: {exc}"
)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255))
try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
# tray callbacks
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool,
host: str = '127.0.0.1'):
global _async_stop
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(
tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "10048" in str(exc) or "Address already in use" in str(exc):
_show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.")
finally:
loop.close()
_async_stop = None
def start_proxy():
global _proxy_thread, _config
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
cfg = _config
port = cfg.get("port", DEFAULT_CONFIG["port"])
host = cfg.get("host", DEFAULT_CONFIG["host"])
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
verbose = cfg.get("verbose", False)
try:
dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
_show_error(f"Ошибка конфигурации:\n{e}")
return
log.info("Starting proxy on %s:%d ...", host, port)
buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])
pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])
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)
_proxy_thread = threading.Thread(
target=_run_proxy_thread,
args=(port, dc_opt, verbose, host),
daemon=True, name="proxy")
_proxy_thread.start()
def stop_proxy():
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=5)
if _proxy_thread.is_alive():
log.warning(
"Proxy thread did not finish within timeout; "
"the process may still exit shortly")
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy():
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
_user32.MessageBoxW(None, text, title, 0x10)
def _show_info(text: str, title: str = "TG WS Proxy"):
_user32.MessageBoxW(None, text, title, 0x40)
def _ask_open_release_page(latest_version: str, url: str) -> bool:
"""Win32 Yes/No: открыть страницу релиза."""
MB_YESNO = 0x4
MB_ICONQUESTION = 0x20
IDYES = 6
text = (
f"Доступна новая версия: {latest_version}\n\n"
f"Открыть страницу релиза в браузере?"
)
r = _user32.MessageBoxW(
None,
text,
"TG WS Proxy — обновление",
MB_YESNO | MB_ICONQUESTION,
)
return r == IDYES
def _maybe_notify_update_async():
"""
Фоновая проверка GitHub Releases и уведомление (не блокирует трей).
"""
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if _ask_open_release_page(str(ver), url):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Opening %s", url)
try:
result = webbrowser.open(url)
if not result:
raise RuntimeError("webbrowser.open returned False")
if not webbrowser.open(url):
raise RuntimeError
except Exception:
log.info("Browser open failed, copying to clipboard")
if pyperclip is None:
_show_error(
"Не удалось открыть Telegram автоматически.\n\n"
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}")
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
)
return
try:
pyperclip.copy(url)
_show_info(
f"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}",
"TG WS Proxy")
"Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(icon=None, item=None):
threading.Thread(target=restart_proxy, daemon=True).start()
def _on_copy_link(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
if pyperclip is None:
_show_error(
"Установите пакет pyperclip для копирования в буфер обмена."
)
return
try:
pyperclip.copy(url)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_edit_config(icon=None, item=None):
def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error), daemon=True
).start()
def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog():
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled()
# Make sure that the autostart key is removed if autostart
# is disabled, even if the executable file is moved.
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
if _supports_autostart():
h += 100
icon_path = str(Path(__file__).parent / "icon.ico")
root = create_ctk_root(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=lambda r: r.iconbitmap(icon_path),
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk,
scroll,
theme,
cfg,
DEFAULT_CONFIG,
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
)
def on_save():
merged = validate_config_form(
widgets,
DEFAULT_CONFIG,
include_autostart=_supports_autostart(),
)
if isinstance(merged, str):
_show_error(merged)
return
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
if _supports_autostart():
set_autostart_enabled(bool(new_cfg.get("autostart", False)))
_tray_icon.menu = _build_menu()
# Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
# и даёт зависание; tkinter.messagebox согласован с циклом окна.
from tkinter import messagebox
if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n"
"Перезапустить прокси сейчас?",
parent=root):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=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))
else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy")
_show_info("Файл логов ещё не создан.")
def _on_exit(icon=None, item=None):
def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("User requested exit")
def _force_exit():
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
quit_ctk()
threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
if icon:
icon.stop()
# settings dialog
def _show_first_run():
_ensure_dirs()
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk):
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled()
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
if _supports_autostart():
h += 100
root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
)
def _finish() -> None:
root.destroy()
done.set()
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
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu()
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
ctk_run_dialog(_build)
# first run
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists():
return
if not ensure_ctk_thread(ctk):
FIRST_RUN_MARKER.touch()
return
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
if ctk is None:
FIRST_RUN_MARKER.touch()
return
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
theme = ctk_theme_for_platform()
icon_path = str(Path(__file__).parent / "icon.ico")
w, h = FIRST_RUN_SIZE
root = create_ctk_root(
ctk,
title="TG WS Proxy",
width=w,
height=h,
theme=theme,
after_create=lambda r: r.iconbitmap(icon_path),
)
def on_done(open_tg: bool) -> None:
FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
def on_done(open_tg: bool):
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
ctk_run_dialog(_build)
def _has_ipv6_enabled() -> bool:
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if not ip or ip.startswith("::1"):
continue
try:
if ipaddress.IPv6Address(ip).is_link_local:
continue
except ValueError:
if ip.startswith("fe80:"):
continue
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy")
# tray menu
def _build_menu():
if pystray is None:
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)
return pystray.Menu(
pystray.MenuItem(
f"Открыть в Telegram ({host}:{port})",
_on_open_in_telegram,
default=True),
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config),
@@ -709,29 +310,17 @@ def _build_menu():
)
def run_tray():
# entry point
def run_tray() -> None:
global _tray_icon, _config
_config = load_config()
save_config(_config)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
bootstrap(_config)
if pystray is None or Image is None or ctk is None:
log.error(
"pystray, Pillow or customtkinter not installed; "
"running in console mode")
start_proxy()
log.error("pystray, Pillow or customtkinter not installed; running in console mode")
start_proxy(_config, _show_error)
try:
while True:
time.sleep(1)
@@ -739,20 +328,12 @@ def run_tray():
stop_proxy()
return
start_proxy()
_maybe_notify_update_async()
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_show_first_run()
_check_ipv6_warning()
icon_image = _load_icon()
_tray_icon = pystray.Icon(
APP_NAME,
icon_image,
"TG WS Proxy",
menu=_build_menu())
check_ipv6_warning(_show_info)
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running")
_tray_icon.run()
@@ -760,15 +341,14 @@ def run_tray():
log.info("Tray app exited")
def main():
if not _acquire_lock():
def main() -> None:
if not acquire_lock("windows.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return
try:
run_tray()
finally:
_release_lock()
release_lock()
if __name__ == "__main__":