Compare commits
11 Commits
3f8a5f77f5
...
691e3b6e3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 691e3b6e3f | |||
| bc0c8f5fc9 | |||
| ca53e4804c | |||
| 55057b836a | |||
| 95d2fd5614 | |||
| d0a8fd3f4e | |||
| 899adfa517 | |||
| c58febcbcf | |||
| 4a9620ab8b | |||
| 63ba2cf1d9 | |||
| 6384446a44 |
Executable
+303
@@ -0,0 +1,303 @@
|
|||||||
|
name: Build WDTT Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release:
|
||||||
|
name: Build release APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ANDROID_MIN_API: "29"
|
||||||
|
ANDROID_COMPILE_SDK: "35"
|
||||||
|
ANDROID_BUILD_TOOLS: "35.0.0"
|
||||||
|
ANDROID_NDK_VERSION: "27.2.12479018"
|
||||||
|
SERVER_BINARY_NAME: "server"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Java 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
sdkmanager \
|
||||||
|
"platforms;android-${ANDROID_COMPILE_SDK}" \
|
||||||
|
"build-tools;${ANDROID_BUILD_TOOLS}" \
|
||||||
|
"ndk;${ANDROID_NDK_VERSION}"
|
||||||
|
|
||||||
|
echo "ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26.x"
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: |
|
||||||
|
go.mod
|
||||||
|
go_client/go.mod
|
||||||
|
|
||||||
|
- name: Generate Go sums
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "== Root Go module =="
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
echo "== Go client module =="
|
||||||
|
cd go_client
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
- name: Build Linux server binaries
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p build/server
|
||||||
|
|
||||||
|
echo "== Build server linux/amd64 =="
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -checklinkname=0" \
|
||||||
|
-o build/server/wdtt-server-linux-amd64 \
|
||||||
|
./server.go
|
||||||
|
|
||||||
|
echo "== Build server linux/arm64 =="
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||||
|
go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -checklinkname=0" \
|
||||||
|
-o build/server/wdtt-server-linux-arm64 \
|
||||||
|
./server.go
|
||||||
|
|
||||||
|
chmod +x build/server/wdtt-server-linux-amd64
|
||||||
|
chmod +x build/server/wdtt-server-linux-arm64
|
||||||
|
|
||||||
|
- name: Put server binary into Android assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p app/src/main/assets
|
||||||
|
|
||||||
|
# Android deploy code expects this exact asset name.
|
||||||
|
cp build/server/wdtt-server-linux-amd64 app/src/main/assets/${SERVER_BINARY_NAME}
|
||||||
|
chmod +x app/src/main/assets/${SERVER_BINARY_NAME}
|
||||||
|
|
||||||
|
echo "Server asset:"
|
||||||
|
ls -lh app/src/main/assets/${SERVER_BINARY_NAME}
|
||||||
|
|
||||||
|
- name: Build Android Go client libraries
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME}"
|
||||||
|
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
|
|
||||||
|
if [ ! -d "$TOOLCHAIN" ]; then
|
||||||
|
echo "Android NDK toolchain not found: $TOOLCHAIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using NDK: $NDK_ROOT"
|
||||||
|
echo "Using toolchain: $TOOLCHAIN"
|
||||||
|
|
||||||
|
mkdir -p app/src/main/jniLibs/arm64-v8a
|
||||||
|
mkdir -p app/src/main/jniLibs/armeabi-v7a
|
||||||
|
mkdir -p app/src/main/jniLibs/x86_64
|
||||||
|
mkdir -p build/go-client
|
||||||
|
|
||||||
|
cd go_client
|
||||||
|
|
||||||
|
echo "== Build libclient.so for arm64-v8a =="
|
||||||
|
CGO_ENABLED=1 \
|
||||||
|
GOOS=android \
|
||||||
|
GOARCH=arm64 \
|
||||||
|
CC="$TOOLCHAIN/aarch64-linux-android${ANDROID_MIN_API}-clang" \
|
||||||
|
go build \
|
||||||
|
-buildmode=c-shared \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -checklinkname=0" \
|
||||||
|
-o ../app/src/main/jniLibs/arm64-v8a/libclient.so \
|
||||||
|
.
|
||||||
|
|
||||||
|
cp ../app/src/main/jniLibs/arm64-v8a/libclient.so \
|
||||||
|
../build/go-client/libclient-arm64-v8a.so
|
||||||
|
|
||||||
|
echo "== Build libclient.so for armeabi-v7a =="
|
||||||
|
CGO_ENABLED=1 \
|
||||||
|
GOOS=android \
|
||||||
|
GOARCH=arm \
|
||||||
|
GOARM=7 \
|
||||||
|
CC="$TOOLCHAIN/armv7a-linux-androideabi${ANDROID_MIN_API}-clang" \
|
||||||
|
go build \
|
||||||
|
-buildmode=c-shared \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -checklinkname=0" \
|
||||||
|
-o ../app/src/main/jniLibs/armeabi-v7a/libclient.so \
|
||||||
|
.
|
||||||
|
|
||||||
|
cp ../app/src/main/jniLibs/armeabi-v7a/libclient.so \
|
||||||
|
../build/go-client/libclient-armeabi-v7a.so
|
||||||
|
|
||||||
|
echo "== Build libclient.so for x86_64 =="
|
||||||
|
CGO_ENABLED=1 \
|
||||||
|
GOOS=android \
|
||||||
|
GOARCH=amd64 \
|
||||||
|
CC="$TOOLCHAIN/x86_64-linux-android${ANDROID_MIN_API}-clang" \
|
||||||
|
go build \
|
||||||
|
-buildmode=c-shared \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags="-s -w -checklinkname=0" \
|
||||||
|
-o ../app/src/main/jniLibs/x86_64/libclient.so \
|
||||||
|
.
|
||||||
|
|
||||||
|
cp ../app/src/main/jniLibs/x86_64/libclient.so \
|
||||||
|
../build/go-client/libclient-x86_64.so
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "Built JNI libraries:"
|
||||||
|
find app/src/main/jniLibs -type f -name "libclient.so" -exec ls -lh {} \;
|
||||||
|
|
||||||
|
- name: Make Gradle wrapper executable
|
||||||
|
shell: bash
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Generate temporary release keystore
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEYSTORE_PASSWORD="wdtt_temp_release_password"
|
||||||
|
KEY_PASSWORD="$KEYSTORE_PASSWORD"
|
||||||
|
KEY_ALIAS="wdtt-release"
|
||||||
|
|
||||||
|
rm -f release.keystore local.properties signing.env
|
||||||
|
|
||||||
|
keytool -genkeypair -v -keystore release.keystore -storetype PKCS12 -storepass "$KEYSTORE_PASSWORD" -keypass "$KEY_PASSWORD" -alias "$KEY_ALIAS" -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=WDTT,O=WDTT,C=LV"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD"
|
||||||
|
echo "KEY_PASSWORD=$KEY_PASSWORD"
|
||||||
|
echo "KEY_ALIAS=$KEY_ALIAS"
|
||||||
|
} > signing.env
|
||||||
|
|
||||||
|
echo "Generated temporary release keystore"
|
||||||
|
ls -lh release.keystore
|
||||||
|
|
||||||
|
- name: Build Android release APK
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# No local.properties is created here, so Gradle produces unsigned release APKs.
|
||||||
|
# The workflow signs them manually in the next step using apksigner.
|
||||||
|
./gradlew clean :app:assembleRelease --stacktrace
|
||||||
|
|
||||||
|
- name: Sign release APKs manually
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source signing.env
|
||||||
|
|
||||||
|
BUILD_TOOLS="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
|
||||||
|
ZIPALIGN="$BUILD_TOOLS/zipalign"
|
||||||
|
APKSIGNER="$BUILD_TOOLS/apksigner"
|
||||||
|
|
||||||
|
echo "Using zipalign: $ZIPALIGN"
|
||||||
|
echo "Using apksigner: $APKSIGNER"
|
||||||
|
|
||||||
|
mkdir -p build/signed-apk
|
||||||
|
shopt -s nullglob
|
||||||
|
unsigned_apks=(app/build/outputs/apk/release/*-unsigned.apk)
|
||||||
|
|
||||||
|
if [ ${#unsigned_apks[@]} -eq 0 ]; then
|
||||||
|
echo "No unsigned release APKs found"
|
||||||
|
echo "Available APK outputs:"
|
||||||
|
find app/build/outputs/apk -type f -name "*.apk" -print || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for apk in "${unsigned_apks[@]}"; do
|
||||||
|
name="$(basename "$apk" -unsigned.apk)"
|
||||||
|
aligned="build/signed-apk/${name}-aligned.apk"
|
||||||
|
signed="build/signed-apk/${name}-signed.apk"
|
||||||
|
|
||||||
|
echo "== Align $apk =="
|
||||||
|
"$ZIPALIGN" -p -f 4 "$apk" "$aligned"
|
||||||
|
|
||||||
|
echo "== Sign $aligned =="
|
||||||
|
"$APKSIGNER" sign --ks release.keystore --ks-key-alias "$KEY_ALIAS" --ks-pass "pass:$KEYSTORE_PASSWORD" --key-pass "pass:$KEY_PASSWORD" --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false --out "$signed" "$aligned"
|
||||||
|
|
||||||
|
echo "== Verify $signed =="
|
||||||
|
"$APKSIGNER" verify --verbose --print-certs "$signed"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Signed APKs:"
|
||||||
|
ls -lh build/signed-apk/*-signed.apk
|
||||||
|
|
||||||
|
- name: Collect build outputs
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p build/artifacts/apk
|
||||||
|
mkdir -p build/artifacts/server
|
||||||
|
mkdir -p build/artifacts/go-client
|
||||||
|
|
||||||
|
echo "== Signed APK outputs =="
|
||||||
|
find build/signed-apk -type f -name "*-signed.apk" -print -exec ls -lh {} \;
|
||||||
|
|
||||||
|
cp build/signed-apk/*-signed.apk build/artifacts/apk/
|
||||||
|
cp build/server/* build/artifacts/server/
|
||||||
|
cp build/go-client/* build/artifacts/go-client/
|
||||||
|
|
||||||
|
echo "== Final artifacts =="
|
||||||
|
find build/artifacts -type f -exec ls -lh {} \;
|
||||||
|
|
||||||
|
- name: Upload release APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: WDTT-release-apk
|
||||||
|
path: build/artifacts/apk/*.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload server binaries artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: WDTT-server-binaries
|
||||||
|
path: build/artifacts/server/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload Go client libraries artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: WDTT-go-client-libraries
|
||||||
|
path: build/artifacts/go-client/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# WDTT — WireGuard over TURN Tunnel
|
# WDTT — WireGuard over TURN Tunnel
|
||||||
<br>
|
<br>
|
||||||
<img src="https://img.shields.io/badge/Android-SDK_29--36-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Android SDK">
|
<img src="https://img.shields.io/badge/Android-SDK_29--35-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Android SDK">
|
||||||
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go Version">
|
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go Version">
|
||||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" alt="Kotlin">
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" alt="Kotlin">
|
||||||
<a href="https://github.com/amurcanov/proxy-turn-vk-android/stargazers">
|
<a href="https://github.com/amurcanov/proxy-turn-vk-android/stargazers">
|
||||||
@@ -13,69 +13,111 @@
|
|||||||
|
|
||||||
**WDTT** — это Android-приложение для создания защищённого **WireGuard-туннеля поверх TURN/DTLS**. Клиент поднимает локальный VPN-интерфейс на устройстве, получает WireGuard-конфигурацию от вашего VPS и передаёт транспорт через TURN-серверы VK, маскируя соединение под обычный зашифрованный медиатрафик звонка.
|
**WDTT** — это Android-приложение для создания защищённого **WireGuard-туннеля поверх TURN/DTLS**. Клиент поднимает локальный VPN-интерфейс на устройстве, получает WireGuard-конфигурацию от вашего VPS и передаёт транспорт через TURN-серверы VK, маскируя соединение под обычный зашифрованный медиатрафик звонка.
|
||||||
|
|
||||||
---
|
<img width="1000" height="738" alt="MyCollages (1)" src="https://github.com/user-attachments/assets/549c6624-978c-4b00-aae2-d195e470a1d1" />
|
||||||
|
|
||||||
<img width="1000" height="675" alt="MyCollages (2)" src="https://github.com/user-attachments/assets/39c1cf09-c3a9-4bf3-9de5-e5827134b1c3" />
|
## Содержание
|
||||||
|
|
||||||
|
- [Возможности Android-версии](#возможности-android-версии)
|
||||||
|
- [Что нового в версии 1.1.8](#что-нового-в-версии-118)
|
||||||
|
- [**Другие рабочие решения**](#другие-рабочие-решения)
|
||||||
|
- [Как это работает](#как-это-работает)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [Получение VK-хеша](#получение-vk-хеша)
|
||||||
|
- [Деплой VPS](#деплой-vps)
|
||||||
|
- [Управление доступом](#управление-доступом)
|
||||||
|
- [Дополнительные возможности](#дополнительные-возможности)
|
||||||
|
- [Лицензия](#лицензия)
|
||||||
|
|
||||||
## Возможности Android-версии
|
## Возможности Android-версии
|
||||||
|
|
||||||
- **Полноценный VPN-режим:** приложение использует `VpnService` и WireGuard GoBackend, поэтому трафик выбранных приложений проходит через системный VPN-интерфейс без ручного импорта конфигов.
|
- **Полноценный VPN-режим:** приложение использует `VpnService` и WireGuard GoBackend, поэтому трафик выбранных приложений проходит через системный VPN-интерфейс без ручного импорта конфигов.
|
||||||
- **TURN/DTLS-транспорт:** нативный Go-клиент получает временные TURN-учётные данные VK-звонка и поднимает DTLS-соединения к relay-серверу, через который передаётся трафик до вашего VPS.
|
- **TURN/DTLS-транспорт:** нативный Go-клиент получает временные TURN-учётные данные VK-звонка и поднимает DTLS-соединения к relay-серверу, через который передаётся трафик до вашего VPS.
|
||||||
|
- **WRAP без захардкоженного ключа:** внешний RTP AEAD/ChaCha20-Poly1305 слой выводится из пароля подключения через HKDF. Один пароль — один WRAP-ключ, ключ не хранится в APK.
|
||||||
|
- **RTP AEAD обфускация:** транспорт маскируется под RTP/WebRTC аудиотрафик с OPUS payload type, а полезная нагрузка шифруется AEAD-слоем поверх DTLS/WireGuard.
|
||||||
- **Деплой с телефона:** вкладка **«Деплой»** подключается к серверу по SSH, загружает `wdtt-server`, создаёт `systemd`-сервис, включает NAT/firewall и открывает рабочие UDP-порты.
|
- **Деплой с телефона:** вкладка **«Деплой»** подключается к серверу по SSH, загружает `wdtt-server`, создаёт `systemd`-сервис, включает NAT/firewall и открывает рабочие UDP-порты.
|
||||||
- **Парольная модель доступа:** сервер поддерживает главный пароль, одноразовые/срочные пароли, привязку пароля к устройству и управление через Telegram-бота.
|
- **Парольная модель доступа:** сервер поддерживает главный пароль, до 10 активных пользовательских паролей, 16-символьную генерацию, привязку пароля к устройству, истечение срока и управление через Telegram-бота.
|
||||||
|
- **Горячее обновление WRAP-ключей:** создание, удаление и истечение паролей обновляют набор WRAP-ключей на сервере без перезапуска ядра.
|
||||||
- **Исключения приложений:** можно выбрать, какие приложения идут через туннель, а какие работают напрямую. Поддерживаются режимы ЧС и БС, а изменения применяются перезагрузкой WireGuard без полного перенастраивания.
|
- **Исключения приложений:** можно выбрать, какие приложения идут через туннель, а какие работают напрямую. Поддерживаются режимы ЧС и БС, а изменения применяются перезагрузкой WireGuard без полного перенастраивания.
|
||||||
- **Капча VK Smart Captcha:** основной рабочий режим в текущей Android-сборке — `WBV/WebView` с ручным решением или автоматической попыткой для простых сценариев. RJS-логика есть в Go-ядре, но в UI текущей сборки временно отключена.
|
- **Капча VK Smart Captcha:** по умолчанию включён режим **«Авто капча»**: Go v2-решатель, Auto WebView и ручной WebView используются по цепочке без выбора режима пользователем. При отключении авто-режима доступны ручные настройки метода.
|
||||||
- **Живой лог-вьюер:** события Go-клиента, DTLS, WireGuard, капчи, деплоя и статистики отображаются в приложении с группировкой одинаковых сообщений и счётчиками повторов.
|
- **Живой лог-вьюер:** события Go-клиента, DTLS, WireGuard, WRAP, капчи, деплоя и статистики отображаются в приложении с группировкой одинаковых сообщений и счётчиками повторов.
|
||||||
- **Фоновая устойчивость:** `Foreground Service`, `WakeLock`, `WifiLock`, мониторинг смены сети и watchdog помогают переживать Doze, смену Wi-Fi/LTE и падение нативного процесса.
|
- **Фоновая устойчивость:** `Foreground Service`, `WakeLock`, `WifiLock`, мониторинг смены сети и watchdog помогают переживать Doze, смену Wi-Fi/LTE и падение нативного процесса.
|
||||||
|
- **VK Auth fallback:** клиент использует 2 проверенных VK `client_id` и переключается между ними при ошибках авторизации.
|
||||||
|
- **DNS fallback:** сначала используются Yandex DNS `77.88.8.8` / `77.88.8.1`, а при отказе или таймаутах выполняется fallback на системный провайдерский DNS устройства.
|
||||||
- **Темы и оформление:** Material 3, Jetpack Compose, Inter, светлая/тёмная тема, Dynamic Colors на Android 12+ и встроенные палитры.
|
- **Темы и оформление:** Material 3, Jetpack Compose, Inter, светлая/тёмная тема, Dynamic Colors на Android 12+ и встроенные палитры.
|
||||||
- **Автообновления:** приложение проверяет GitHub releases, показывает диалог обновления и позволяет перейти на страницу актуального релиза.
|
- **Автообновления:** приложение проверяет GitHub releases, показывает диалог обновления и периодически повторяет проверку в фоне.
|
||||||
|
|
||||||
## Что нового в версии 1.1.0
|
## Что нового в версии 1.1.8
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> После обновления до **1.1.0** необходимо заново выполнить **деплой сервера** из приложения.
|
> Если сервер уже деплоился под актуальный WRAP/RTP AEAD протокол из ветки `1.1.7+`, повторный деплой для **1.1.8** не нужен. Если вы обновляетесь со старой серверной версии без password-based WRAP и RTP AEAD, выполните деплой сервера заново.
|
||||||
|
|
||||||
* **Изоляция WDTT:** деплой, удаление и рабочая среда **WDTT** теперь не должны влиять на другие компоненты VPS. Серверная часть изолирована в собственной конфигурации, интерфейсе и правилах firewall/NAT.
|
* **VK Auth:** переход с 5 `client_id` на 2 проверенных `client_id`: `6287487` и `8202606`. Клиент сначала использует `6287487`, затем `8202606`, после чего повторяет fallback-цикл.
|
||||||
* **Автообновление:** помимо ручной проверки, приложение теперь самостоятельно проверяет наличие новых версий и предлагает обновиться до актуального релиза.
|
* **Стабильность VK API:** удалены проблемные `client_id`, из-за которых могло появляться `Unknown method passed` для `calls.getAnonymousToken`.
|
||||||
* **Ручные порты:** добавлена возможность управлять портами. При включении режима ручных портов можно задать **DTLS** и **WG** порты на сервере, а также локальный VPN-порт в туннеле. Если это не нужно, режим лучше не включать.
|
* **DNS:** добавлен fallback на системный провайдерский DNS, если наблюдаются проблемы с Yandex DNS `77.88.8.8` / `77.88.8.1`.
|
||||||
* **Капча:** возвращён режим **Авто-WBV** для прохождения капчи **«Я не робот»**. Режим **RJS** временно заблокирован до следующих улучшений. Если с **Авто-WBV** возникают проблемы, используйте ручной режим.
|
* **VK Join Link:** исправлена обработка полных ссылок VK Call Join, чтобы вместо корректного хеша в запрос не попадало значение вроде `https:`.
|
||||||
* **Разделение архитектур:** релизы теперь делятся на **arm64-v8a**, **armeabi-v7a** и **x86_64**, чтобы уменьшить размер APK. Если вы не знаете, какой APK выбрать, используйте **Universal** — он содержит все 3 архитектуры, но весит больше.
|
* **WRAP/DTLS:** исправлен ложный стоп с сообщением **«Неверный пароль подключения»**, когда один поток ловил DTLS timeout, но остальные потоки уже были активны.
|
||||||
* **Сборка:** обновлены **AGP**, **Gradle** и **Kotlin** до актуальных версий (`9.0.1`, `9.1.0`, `2.x`), что положительно влияет на стабильность и работу приложения.
|
* **WRAP-ключи:** захардкоженный WRAP-ключ заменён на HKDF-деривацию из пароля подключения. Сервер держит активные ключи в памяти и обновляет их при изменении паролей.
|
||||||
* **Багфиксы и стабильность:** удалён **DataSync**, который мог вызывать краши на **Android 14+**; изменено поведение уведомления, чтобы оно не скакало в шторке; улучшен запуск **VPN Service** и передача WireGuard-конфига.
|
* **Обфускация:** добавлен RTP-over-ChaCha20-Poly1305 AEAD слой, маскирующий внешний транспорт под WebRTC аудиопоток.
|
||||||
* **Интерфейс и информация:** проведён небольшой редизайн, добавлены тени и орбы на фон. Раздел **«Инфо»** переработан и теперь позволяет собрать отчёт с данными об устройстве для более точного разбора ошибок.
|
* **Диспетчер:** переход с Round-Robin на Adaptive Chunking в single-flow режиме с сохранением высокой агрегатной скорости multi-flow.
|
||||||
* **В планах:** заменить стандартный протокол **WireGuard** на **AmneziaWG** в версиях `1.5-2.0`, чтобы лучше решать проблемы региональных блокировок.
|
* **Потоки и хеши:** добавлена поддержка 4-го хеша VK-звонка; при 4 хешах доступно до 108 потоков. Исправлен баг слайдера потоков.
|
||||||
* **Откат при проблемах:** если после обновления появились ошибки, которых раньше точно не было, можно открыть `issue` и временно откатиться на версию **1.0.6**.
|
* **Капча:** режим прохождения стал автоматическим: сначала Go v2, затем Auto WebView, затем финальная Go-попытка и только после этого ручной WebView.
|
||||||
|
* **Автообновление:** исправлена проверка обновлений, которая раньше могла выполняться только один раз при старте приложения.
|
||||||
|
* **Keepalive:** добавлен DTLS keepalive для длительных сессий, чтобы снизить вероятность `reader EOF`.
|
||||||
|
|
||||||
---
|
## Другие рабочие решения
|
||||||
|
|
||||||
|
Рабочие решения с обходом шейпинга скорости и частыми обновлениями:
|
||||||
|
|
||||||
|
**Моё любимое**
|
||||||
|
- [Moroka8/vk-turn-proxy](https://github.com/Moroka8/vk-turn-proxy) — Качественные ядра клиента и сервера
|
||||||
|
|
||||||
|
**Android**
|
||||||
|
- [samosvalishe/turn-proxy-android](https://github.com/samosvalishe/turn-proxy-android) — альтернативная андроид версия
|
||||||
|
|
||||||
|
**iOS**
|
||||||
|
- [anton48/vk-turn-proxy-ios](https://github.com/anton48/vk-turn-proxy-ios) — версия под ios
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Android-приложение → VpnService / WireGuard GoBackend → локальный UDP 127.0.0.1:9000
|
Android-приложение → VpnService / WireGuard GoBackend → локальный UDP 127.0.0.1:9000
|
||||||
→ Go-клиент WDTT → VK TURN / DTLS → wdtt-server на VPS → интернет
|
→ Go-клиент WDTT → WRAP RTP AEAD → VK TURN / DTLS → wdtt-server на VPS → интернет
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Приложение запускает нативный Go-клиент `libclient.so` и передаёт ему адрес VPS, VK-хеши звонка, пароль туннеля, протокол TURN и количество потоков.
|
1. Приложение запускает нативный Go-клиент `libclient.so` и передаёт ему адрес VPS, VK-хеши звонка, пароль туннеля и количество потоков.
|
||||||
2. Go-клиент получает TURN-учётные данные через VK-звонок, при необходимости решает VK Smart Captcha и устанавливает DTLS-соединения через TURN relay.
|
2. Go-клиент выводит WRAP-ключ из пароля подключения, получает TURN-учётные данные через VK-звонок, при необходимости решает VK Smart Captcha и устанавливает DTLS-соединения через TURN relay.
|
||||||
3. Первый рабочий канал запрашивает у VPS WireGuard-конфигурацию через `GETCONF`, передавая локальный порт, `device-id` и пароль подключения.
|
3. Внешний WRAP-слой упаковывает DTLS-пакеты в RTP AEAD/ChaCha20-Poly1305, чтобы ключ не был захардкожен в APK и мог отличаться у каждого пароля.
|
||||||
4. Сервер проверяет пароль: главный пароль работает как владелец, сгенерированные пароли могут иметь срок действия и привязываются к первому устройству.
|
4. Первый рабочий канал запрашивает у VPS WireGuard-конфигурацию через `GETCONF`, передавая локальный порт, `device-id` и пароль подключения.
|
||||||
5. Android-часть парсит полученный WireGuard-конфиг, поднимает системный VPN-туннель и применяет исключения приложений.
|
5. Сервер проверяет пароль: главный пароль работает как владелец, сгенерированные пароли имеют срок действия и привязываются к первому устройству.
|
||||||
6. Watchdog следит за Go-процессом, активными воркерами и сетевыми изменениями, перезапуская транспорт при сбоях.
|
6. Android-часть парсит полученный WireGuard-конфиг, поднимает системный VPN-туннель и применяет исключения приложений.
|
||||||
|
7. Watchdog следит за Go-процессом, активными воркерами и сетевыми изменениями, перезапуская транспорт при сбоях.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Видеогайд по настройке и использованию WDTT (Показано на версии v1.1.4.)
|
||||||
|
|
||||||
|
<img width="720" height="404" alt="hq720" src="https://github.com/user-attachments/assets/6538d2bb-2eeb-4de8-a3d7-5a6595e8175f" />
|
||||||
|
|
||||||
|
[**Смотреть гайд на YouTube**](https://www.youtube.com/watch?v=JFHn9jmPbfY&t=54s)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
```Важно гайд показан на уже устаревшей версии 1.1.4 но в нем есть много полезной информации, показана работа и настройка. Приобретение VPS и т,д. В новых версиях 1.1.8+ формат хешей изменен - достаточно вставить просто чистый хеш или проще = целиком ссылку звонка.```
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/proxy-turn-vk-android/releases)**.
|
1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/proxy-turn-vk-android/releases)**.
|
||||||
2. Установите приложение на Android-смартфон.
|
2. Установите приложение на Android-смартфон.
|
||||||
3. Подготовьте VPS с root-доступом или пользователем с `sudo`.
|
3. Подготовьте айпи, логин, пароль от имеющегося VPS.
|
||||||
4. В VK создайте или откройте групповой звонок и скопируйте ссылку вида `vk.com/call/join/xxxxxxxxxxx`.
|
4. В VK создайте или откройте групповой звонок и скопируйте ссылку вида `vk.com/call/join/xxxxxxxxxxx`. где xxxxxxxxxxx ваш хеш, или просто вставьте ссылку.
|
||||||
5. Откройте **WDTT** и перейдите во вкладку **«Деплой»**.
|
5. Откройте **WDTT** и перейдите во вкладку **«Деплой»**.
|
||||||
6. Введите IP/домен VPS, SSH-логин, пароль и SSH-порт.
|
6. Введите IP/домен VPS, SSH-логин, пароль и SSH-порт.
|
||||||
7. В **«Секретах»** задайте пароль туннеля. При необходимости добавьте Telegram `admin_id` и `bot_token` для управления паролями.
|
7. В **«Секретах»** задайте главный пароль туннеля. При необходимости добавьте Telegram `admin_id` / `bot_token` для управления доступом.
|
||||||
8. Нажмите **«Установить»** и дождитесь завершения деплоя.
|
8. Нажмите **«Установить»** и дождитесь завершения деплоя.
|
||||||
9. Во вкладке **«Туннель»** укажите IP/домен сервера, VK-хеши, пароль туннеля и количество потоков.
|
9. Во вкладке **«Туннель»** укажите IP/домен сервера, VK-хеши, пароль подключения и количество потоков.
|
||||||
10. Нажмите **«Подключить»** и выдайте Android-разрешение на VPN.
|
10. Нажмите **«Подключить»** предварительно выдав все необходимые разрешения приложению.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Получение VK-хеша
|
## Получение VK-хеша
|
||||||
|
|
||||||
@@ -86,8 +128,8 @@ VK → группа → звонок → ссылка приглашения →
|
|||||||
1. Откройте VK и создайте пустую группу или используйте существующую.
|
1. Откройте VK и создайте пустую группу или используйте существующую.
|
||||||
2. Начните групповой звонок.
|
2. Начните групповой звонок.
|
||||||
3. Скопируйте ссылку приглашения.
|
3. Скопируйте ссылку приглашения.
|
||||||
4. Вставьте в WDTT всю ссылку или только хеш после последнего слэша.
|
4. Вставьте в WDTT всю ссылку или только хеш после `/join/`.
|
||||||
5. Можно использовать до **3 хешей** одновременно для распределения нагрузки и увеличения доступного числа потоков.
|
5. Можно использовать до **4 хешей** одновременно для распределения нагрузки и увеличения доступного числа потоков.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> При выходе из звонка нажимайте **«Просто завершить»**, а не **«Завершить для всех»**. Если закрыть комнату для всех участников, хеш перестанет работать.
|
> При выходе из звонка нажимайте **«Просто завершить»**, а не **«Завершить для всех»**. Если закрыть комнату для всех участников, хеш перестанет работать.
|
||||||
@@ -115,11 +157,11 @@ Android → SSH → /tmp/deploy.sh + /tmp/wdtt-server → /usr/local/bin/wdtt-se
|
|||||||
WDTT-сервер поддерживает две модели подключения:
|
WDTT-сервер поддерживает две модели подключения:
|
||||||
|
|
||||||
- **Главный пароль:** задаётся при деплое и используется владельцем сервера.
|
- **Главный пароль:** задаётся при деплое и используется владельцем сервера.
|
||||||
- **Сгенерированные пароли:** создаются через Telegram-бота командой `/new`, имеют срок действия и привязываются к первому устройству.
|
- **Сгенерированные пароли:** создаются при деплое или через Telegram-бота, имеют срок действия, привязываются к первому устройству и обновляют WRAP-ключи на сервере без перезапуска.
|
||||||
|
|
||||||
Команда `/list` показывает активные пароли и устройства. Через inline-кнопки можно отвязать устройство или удалить пароль.
|
Один сервер может держать до **10 активных паролей**. Новые пароли генерируются длиной **16 символов**.
|
||||||
|
|
||||||
---
|
Команда `/list` показывает активные пароли и устройства. Через inline-кнопки можно отвязать устройство или удалить пароль. При удалении или истечении пароля соответствующий WRAP-ключ удаляется из памяти сервера.
|
||||||
|
|
||||||
## Дополнительные возможности
|
## Дополнительные возможности
|
||||||
|
|
||||||
@@ -127,23 +169,28 @@ WDTT-сервер поддерживает две модели подключе
|
|||||||
|
|
||||||
Вкладка **«Исключ.»** показывает установленные приложения с поиском. В режиме ЧС выбранные приложения исключаются из VPN, а в режиме БС логика инвертируется: неотмеченные приложения добавляются в туннель. Само приложение WDTT и VK-клиенты исключаются автоматически, чтобы не ломать TURN-соединение.
|
Вкладка **«Исключ.»** показывает установленные приложения с поиском. В режиме ЧС выбранные приложения исключаются из VPN, а в режиме БС логика инвертируется: неотмеченные приложения добавляются в туннель. Само приложение WDTT и VK-клиенты исключаются автоматически, чтобы не ломать TURN-соединение.
|
||||||
|
|
||||||
|
#### Капча
|
||||||
|
|
||||||
|
По умолчанию включён режим **«Авто капча»**. Оркестратор делает до двух попыток Go v2-решателя, затем две попытки Auto WebView с коротким таймаутом, затем финальную Go-попытку. Если всё это не помогло, приложение открывает ручной WebView. Авто-режим можно отключить, тогда в UI появляются ручные настройки метода и режима прохождения.
|
||||||
|
|
||||||
#### Логирование
|
#### Логирование
|
||||||
|
|
||||||
Вкладка **«Логи»** показывает статус получения VK-учётных данных, решение капчи, DTLS-handshake, готовность WireGuard, статистику активных воркеров и ошибки. Повторяющиеся строки схлопываются в одну запись со счётчиком.
|
Вкладка **«Логи»** показывает статус получения VK-учётных данных, решение капчи, DTLS-handshake, WRAP-состояние, готовность WireGuard, статистику активных воркеров и ошибки. Повторяющиеся строки схлопываются в одну запись со счётчиком.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Если видны DTLS handshake, но `Активных: 0`, чаще всего указан неправильный пароль подключения или используется сервер без актуального WRAP-протокола. Если часть потоков уже активна, одиночные DTLS timeout больше не считаются неверным паролем и не останавливают туннель.
|
||||||
|
|
||||||
#### Обновления
|
#### Обновления
|
||||||
|
|
||||||
Приложение проверяет **GitHub releases** репозитория [amurcanov/proxy-turn-vk-android](https://github.com/amurcanov/proxy-turn-vk-android), умеет показывать диалог новой версии и открывать страницу релиза в браузере.
|
Приложение проверяет **GitHub releases** репозитория [amurcanov/proxy-turn-vk-android](https://github.com/amurcanov/proxy-turn-vk-android), умеет показывать диалог новой версии и открывать страницу релиза в браузере. Проверка выполняется при запуске и периодически в фоне.
|
||||||
|
|
||||||
#### Отчёт для issue
|
#### Отчёт для issue
|
||||||
|
|
||||||
В разделе **«Информация»** есть кнопка **«Собрать отчёт»**. Она копирует версию приложения, Android SDK, ABI, модель устройства, SoC, ROM и fingerprint — эти данные полезны при разборе крашей и проблем с запуском.
|
В разделе **«Информация»** есть кнопка **«Собрать отчёт»**. Она копирует версию приложения, Android SDK, ABI, модель устройства, SoC, ROM и fingerprint — эти данные полезны при разборе крашей и проблем с запуском.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> ### Отчёты об ошибках
|
> ### Отчёты об ошибках
|
||||||
> WDTT зависит от мобильной сети, Android-ограничений фоновой работы, состояния VK-звонка, TURN-квот и настроек VPS.
|
> WDTT зависит от мобильной сети, Android-ограничений фоновой работы, состояния VK-звонка, TURN-квот, DNS и настроек VPS.
|
||||||
>
|
>
|
||||||
> Если возникла проблема, приложите к `issue` отчёт из раздела **«Информация»**, скриншот вкладки **«Логи»**, версию APK, ABI сборки и описание сети. Мелкие повторяющиеся ошибки в логах не всегда означают поломку, если туннель остаётся активным.
|
> Если возникла проблема, приложите к `issue` отчёт из раздела **«Информация»**, скриншот вкладки **«Логи»**, версию APK, ABI сборки и описание сети. Мелкие повторяющиеся ошибки в логах не всегда означают поломку, если туннель остаётся активным.
|
||||||
|
|
||||||
@@ -151,8 +198,6 @@ WDTT-сервер поддерживает две модели подключе
|
|||||||
> ### Назначение проекта
|
> ### Назначение проекта
|
||||||
> Приложение является техническим инструментом для защищённого туннелирования собственного трафика через ваш сервер. Автор не призывает использовать WDTT для противоправных целей или нарушения правил сторонних сервисов.
|
> Приложение является техническим инструментом для защищённого туннелирования собственного трафика через ваш сервер. Автор не призывает использовать WDTT для противоправных целей или нарушения правил сторонних сервисов.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Этот проект распространяется под лицензией **GNU General Public License v3.0**.
|
Этот проект распространяется под лицензией **GNU General Public License v3.0**.
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.wdtt.client"
|
applicationId = "com.wdtt.client"
|
||||||
minSdk = 29
|
minSdk = 28
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 118
|
versionCode = 120
|
||||||
versionName = "1.1.8"
|
versionName = "1.2.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.ACTION_QS_TILE_PREFERENCES" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
@@ -41,6 +44,17 @@
|
|||||||
android:value="persistent_vpn_tunnel_transport" />
|
android:value="persistent_vpn_tunnel_transport" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".QuickToggleTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_tile_logo_w"
|
||||||
|
android:label="WDTT"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ManlCaptchaActivity"
|
android:name=".ManlCaptchaActivity"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
@@ -52,6 +66,19 @@
|
|||||||
android:name=".CaptchaCancelReceiver"
|
android:name=".CaptchaCancelReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".VpnWidgetProvider"
|
||||||
|
android:label="WDTT ВПН Виджет"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
<action android:name="com.wdtt.client.ACTION_WIDGET_TOGGLE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/vpn_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -190,17 +190,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
// ═══ Навигация ═══
|
// ═══ Навигация ═══
|
||||||
|
|
||||||
private data class NavItem(
|
private data class NavItem(
|
||||||
|
val id: Int,
|
||||||
val label: String,
|
val label: String,
|
||||||
val selectedIcon: ImageVector,
|
val selectedIcon: ImageVector,
|
||||||
val unselectedIcon: ImageVector,
|
val unselectedIcon: ImageVector,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val navItems = listOf(
|
private val navItems = listOf(
|
||||||
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
|
NavItem(0, "Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
|
||||||
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
|
NavItem(1, "Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
|
||||||
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
|
NavItem(2, "Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
|
||||||
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
|
NavItem(3, "Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
|
||||||
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info),
|
NavItem(4, "Инфо", Icons.Filled.Info, Icons.Outlined.Info),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -220,6 +221,8 @@ fun MainScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
|
||||||
|
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
|
||||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var dragTargetIndex by remember { mutableIntStateOf(-1) }
|
var dragTargetIndex by remember { mutableIntStateOf(-1) }
|
||||||
var dragProgress by remember { mutableFloatStateOf(0f) }
|
var dragProgress by remember { mutableFloatStateOf(0f) }
|
||||||
@@ -231,6 +234,24 @@ fun MainScreen(
|
|||||||
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
|
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
|
||||||
val navOverlayReserve = safeBottomInset + 96.dp
|
val navOverlayReserve = safeBottomInset + 96.dp
|
||||||
|
|
||||||
|
val activeNavItems = remember(wdttLinkMode) {
|
||||||
|
if (wdttLinkMode) {
|
||||||
|
navItems.filter { it.id != 1 }
|
||||||
|
} else {
|
||||||
|
navItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val actionsExpanded = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val projectExpanded = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
LaunchedEffect(wdttLinkMode) {
|
||||||
|
if (wdttLinkMode && selectedTab == 1) {
|
||||||
|
selectedTab = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
||||||
}
|
}
|
||||||
@@ -297,7 +318,7 @@ fun MainScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
.pointerInput(selectedTab) {
|
.pointerInput(selectedTab, wdttLinkMode) {
|
||||||
var totalDrag = 0f
|
var totalDrag = 0f
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
@@ -310,8 +331,8 @@ fun MainScreen(
|
|||||||
dragProgress = 0f
|
dragProgress = 0f
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) {
|
if (dragTargetIndex in activeNavItems.indices && dragProgress >= 0.5f) {
|
||||||
selectedTab = dragTargetIndex
|
selectedTab = activeNavItems[dragTargetIndex].id
|
||||||
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
|
||||||
}
|
}
|
||||||
dragTargetIndex = -1
|
dragTargetIndex = -1
|
||||||
@@ -326,8 +347,9 @@ fun MainScreen(
|
|||||||
return@detectHorizontalDragGestures
|
return@detectHorizontalDragGestures
|
||||||
}
|
}
|
||||||
|
|
||||||
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1
|
val currentActiveIndex = activeNavItems.indexOfFirst { it.id == selectedTab }
|
||||||
if (candidate !in navItems.indices) {
|
val candidate = if (totalDrag < 0f) currentActiveIndex + 1 else currentActiveIndex - 1
|
||||||
|
if (candidate !in activeNavItems.indices) {
|
||||||
dragTargetIndex = -1
|
dragTargetIndex = -1
|
||||||
dragProgress = 0f
|
dragProgress = 0f
|
||||||
return@detectHorizontalDragGestures
|
return@detectHorizontalDragGestures
|
||||||
@@ -350,15 +372,15 @@ fun MainScreen(
|
|||||||
) { tab ->
|
) { tab ->
|
||||||
when (tab) {
|
when (tab) {
|
||||||
0 -> SettingsTab()
|
0 -> SettingsTab()
|
||||||
1 -> DeployTab()
|
1 -> if (!wdttLinkMode) DeployTab() else Spacer(modifier = Modifier.fillMaxSize())
|
||||||
2 -> ExceptionsTab()
|
2 -> ExceptionsTab()
|
||||||
3 -> LogsTab()
|
3 -> LogsTab()
|
||||||
4 -> InfoTab()
|
4 -> InfoTab(actionsExpandedState = actionsExpanded, projectExpandedState = projectExpanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProxyNavigationBar(
|
ProxyNavigationBar(
|
||||||
navItems = navItems,
|
navItems = activeNavItems,
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
dragTargetIndex = dragTargetIndex,
|
dragTargetIndex = dragTargetIndex,
|
||||||
dragProgress = dragProgress,
|
dragProgress = dragProgress,
|
||||||
@@ -380,6 +402,10 @@ fun MainScreen(
|
|||||||
|
|
||||||
// Floating theme toolbar overlay
|
// Floating theme toolbar overlay
|
||||||
FloatingToolbar(
|
FloatingToolbar(
|
||||||
|
activeProfile = activeProfile,
|
||||||
|
onActiveProfileChange = { profile ->
|
||||||
|
scope.launch { settingsStore.saveActiveProfile(profile) }
|
||||||
|
},
|
||||||
currentTheme = themeMode,
|
currentTheme = themeMode,
|
||||||
onThemeChange = onThemeChange,
|
onThemeChange = onThemeChange,
|
||||||
isDynamicColor = isDynamicColor,
|
isDynamicColor = isDynamicColor,
|
||||||
@@ -453,13 +479,16 @@ private fun ProxyNavigationBar(
|
|||||||
} else {
|
} else {
|
||||||
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
|
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
|
||||||
}
|
}
|
||||||
val indicatorIndex = remember { Animatable(selectedTab.toFloat()) }
|
val selectedVisualIndex = remember(selectedTab, navItems) {
|
||||||
|
navItems.indexOfFirst { it.id == selectedTab }.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
val indicatorIndex = remember { Animatable(selectedVisualIndex.toFloat()) }
|
||||||
val dragVisualIndex = indicatorIndex.value
|
val dragVisualIndex = indicatorIndex.value
|
||||||
|
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedVisualIndex) {
|
||||||
if (dragTargetIndex !in navItems.indices) {
|
if (dragTargetIndex !in navItems.indices) {
|
||||||
indicatorIndex.animateTo(
|
indicatorIndex.animateTo(
|
||||||
targetValue = selectedTab.toFloat(),
|
targetValue = selectedVisualIndex.toFloat(),
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 720,
|
durationMillis = 720,
|
||||||
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
|
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
|
||||||
@@ -468,9 +497,9 @@ private fun ProxyNavigationBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) {
|
LaunchedEffect(selectedVisualIndex, dragTargetIndex, dragProgress) {
|
||||||
if (dragTargetIndex in navItems.indices) {
|
if (dragTargetIndex in navItems.indices) {
|
||||||
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress
|
val target = selectedVisualIndex.toFloat() + (dragTargetIndex - selectedVisualIndex) * dragProgress
|
||||||
indicatorIndex.snapTo(target)
|
indicatorIndex.snapTo(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,7 +551,7 @@ private fun ProxyNavigationBar(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.clip(RoundedCornerShape(22.dp))
|
||||||
.clickable { onTabSelected(index) },
|
.clickable { onTabSelected(item.id) },
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -533,7 +562,7 @@ private fun ProxyNavigationBar(
|
|||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = iconColor
|
tint = iconColor
|
||||||
)
|
)
|
||||||
if (index == 3 && unreadErrors > 0) {
|
if (item.id == 3 && unreadErrors > 0) {
|
||||||
Badge(
|
Badge(
|
||||||
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
|
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
|
||||||
contentColor = colors.onPrimary,
|
contentColor = colors.onPrimary,
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.wdtt.client
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.service.quicksettings.Tile
|
||||||
|
import android.service.quicksettings.TileService
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class QuickToggleTileService : TileService() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var stateJob: Job? = null
|
||||||
|
|
||||||
|
override fun onStartListening() {
|
||||||
|
super.onStartListening()
|
||||||
|
// Реактивно подписываемся на статус активности туннеля.
|
||||||
|
// Плитка будет строго отражать РЕАЛЬНОЕ состояние туннеля на 100% без рассинхронизаций.
|
||||||
|
stateJob?.cancel()
|
||||||
|
stateJob = scope.launch {
|
||||||
|
try {
|
||||||
|
TunnelManager.running.collect { running ->
|
||||||
|
updateTile(running)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("QuickToggleTile", "Error collecting running state", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopListening() {
|
||||||
|
stateJob?.cancel()
|
||||||
|
super.onStopListening()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick() {
|
||||||
|
super.onClick()
|
||||||
|
runCatching {
|
||||||
|
if (TunnelManager.running.value) {
|
||||||
|
// Если запущен — останавливаем. Состояние плитки изменится автоматически,
|
||||||
|
// когда TunnelManager остановит процессы и обновит статус running в false.
|
||||||
|
val stopIntent = Intent(this, TunnelService::class.java).apply { action = "STOP" }
|
||||||
|
startService(stopIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие выданного разрешения VPN перед стартом
|
||||||
|
if (VpnService.prepare(this) != null) {
|
||||||
|
Toast.makeText(this, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
|
||||||
|
openMainActivity()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем старт туннеля в фоне
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val intent = buildStartIntent()
|
||||||
|
if (intent == null) {
|
||||||
|
Toast.makeText(this@QuickToggleTileService, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
|
||||||
|
openMainActivity()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("QuickToggleTile", "Failed to start tunnel via QS tile", e)
|
||||||
|
Toast.makeText(this@QuickToggleTileService, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("QuickToggleTile", "Crash prevented in onClick", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildStartIntent(): Intent? {
|
||||||
|
return runCatching {
|
||||||
|
val store = SettingsStore(applicationContext)
|
||||||
|
val basePeer = store.peer.first()
|
||||||
|
val hashes = store.vkHashes.first()
|
||||||
|
val password = store.connectionPassword.first()
|
||||||
|
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
|
||||||
|
|
||||||
|
val manualPortsEnabled = store.manualPortsEnabled.first()
|
||||||
|
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
|
||||||
|
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
|
||||||
|
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
|
||||||
|
|
||||||
|
Intent(this, TunnelService::class.java).apply {
|
||||||
|
action = "START"
|
||||||
|
putExtra("peer", peerWithPort)
|
||||||
|
putExtra("vk_hashes", hashes)
|
||||||
|
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
|
||||||
|
putExtra("workers_per_hash", store.workersPerHash.first())
|
||||||
|
putExtra("port", localPort)
|
||||||
|
putExtra("sni", store.sni.first())
|
||||||
|
putExtra("connection_password", password)
|
||||||
|
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
|
||||||
|
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTile(running: Boolean) {
|
||||||
|
runCatching {
|
||||||
|
qsTile?.apply {
|
||||||
|
label = "WDTT"
|
||||||
|
icon = Icon.createWithResource(this@QuickToggleTileService, R.drawable.ic_tile_logo_w)
|
||||||
|
state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
subtitle = if (running) "Подключено" else "Отключено"
|
||||||
|
}
|
||||||
|
updateTile()
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("QuickToggleTile", "Failed to update QS tile state", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMainActivity() {
|
||||||
|
runCatching {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
100,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
startActivityAndCollapse(pendingIntent)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
startActivityAndCollapse(intent)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("QuickToggleTile", "Failed to open MainActivity", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeCaptchaMode(mode: String?): String {
|
||||||
|
return when (mode?.lowercase()) {
|
||||||
|
"auto" -> "auto"
|
||||||
|
"rjs" -> "rjs"
|
||||||
|
"wv" -> "wv"
|
||||||
|
else -> "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,18 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
companion object {
|
companion object {
|
||||||
private val Context.dataStore by preferencesDataStore("settings")
|
private val Context.dataStore by preferencesDataStore("settings")
|
||||||
|
private val ACTIVE_PROFILE = intPreferencesKey("active_profile")
|
||||||
|
private val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
|
||||||
|
private val LOGGING_ENABLED = booleanPreferencesKey("logging_enabled")
|
||||||
|
private val WDTT_LINK = stringPreferencesKey("wdtt_link")
|
||||||
|
private val WDTT_LINK_MODE = booleanPreferencesKey("wdtt_link_mode")
|
||||||
|
|
||||||
private val PEER = stringPreferencesKey("peer")
|
private val PEER = stringPreferencesKey("peer")
|
||||||
private val VK_HASHES = stringPreferencesKey("vk_hashes")
|
private val VK_HASHES = stringPreferencesKey("vk_hashes")
|
||||||
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
|
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
|
||||||
@@ -83,6 +91,18 @@ class SettingsStore(context: Context) {
|
|||||||
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
|
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
|
||||||
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
|
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
|
||||||
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
|
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
|
||||||
|
|
||||||
|
private fun <T> getProfileKey(baseKey: Preferences.Key<T>, profile: Int): Preferences.Key<T> {
|
||||||
|
if (profile == 0) return baseKey
|
||||||
|
val newName = "${baseKey.name}_$profile"
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return when (baseKey) {
|
||||||
|
PEER, VK_HASHES, SECONDARY_VK_HASH, PROTOCOL, SNI, USER_AGENT, DEPLOY_IP, DEPLOY_LOGIN, DEPLOY_PASSWORD, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_SSH_PORT, EXCLUDED_APPS, CONNECTION_PASSWORD, CONNECTION_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_ADMIN_ID, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_BOT_TOKEN, DEPLOY_BOT_TOKEN_ENCRYPTED, PROXY_MODE, PROXY_HOST, CAPTCHA_MODE, CAPTCHA_SOLVE_METHOD, CAPTCHA_WBV_SOLVE_METHOD, WDTT_LINK -> stringPreferencesKey(newName) as Preferences.Key<T>
|
||||||
|
WORKERS_PER_HASH, LISTEN_PORT, SERVER_DTLS_PORT, SERVER_WG_PORT, PROXY_PORT -> intPreferencesKey(newName) as Preferences.Key<T>
|
||||||
|
MANUAL_PORTS_ENABLED, NO_DTLS, NO_DNS, IS_WHITELIST, WDTT_LINK_MODE -> booleanPreferencesKey(newName) as Preferences.Key<T>
|
||||||
|
else -> throw IllegalArgumentException("Unsupported key type: ${baseKey.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dataStore = appContext.dataStore
|
private val dataStore = appContext.dataStore
|
||||||
@@ -94,65 +114,151 @@ class SettingsStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" }
|
val activeProfile: Flow<Int> = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 }
|
||||||
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" }
|
val showSystemApps: Flow<Boolean> = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true }
|
||||||
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" }
|
val loggingEnabled: Flow<Boolean> = dataStore.data.map { it[LOGGING_ENABLED] ?: true }
|
||||||
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 }
|
val wdttLink: Flow<String> = dataStore.data.map { prefs ->
|
||||||
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" }
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 }
|
prefs[getProfileKey(WDTT_LINK, profile)] ?: ""
|
||||||
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false }
|
}
|
||||||
val serverDtlsPort: Flow<Int> = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 }
|
val wdttLinkMode: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 }
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" }
|
prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false
|
||||||
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false }
|
}
|
||||||
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
|
|
||||||
|
val peer: Flow<String> = dataStore.data.map { prefs ->
|
||||||
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" }
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" }
|
prefs[getProfileKey(PEER, profile)] ?: ""
|
||||||
val deployPassword: Flow<String> = dataStore.data.map {
|
}
|
||||||
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
|
val vkHashes: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(VK_HASHES, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val secondaryVkHash: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val workersPerHash: Flow<Int> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(WORKERS_PER_HASH, profile)] ?: 16
|
||||||
|
}
|
||||||
|
val protocol: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(PROTOCOL, profile)] ?: "udp"
|
||||||
|
}
|
||||||
|
val listenPort: Flow<Int> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(LISTEN_PORT, profile)] ?: 9000
|
||||||
|
}
|
||||||
|
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] ?: false
|
||||||
|
}
|
||||||
|
val serverDtlsPort: Flow<Int> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] ?: 56000
|
||||||
|
}
|
||||||
|
val serverWgPort: Flow<Int> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(SERVER_WG_PORT, profile)] ?: 56001
|
||||||
|
}
|
||||||
|
val sni: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(SNI, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val noDns: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(NO_DNS, profile)] ?: false
|
||||||
|
}
|
||||||
|
val userAgent: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(USER_AGENT, profile)] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val deployIp: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(DEPLOY_IP, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val deployLogin: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(DEPLOY_LOGIN, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val deployPassword: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
readSecret(prefs, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, profile)
|
||||||
|
}
|
||||||
|
val deploySshPort: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] ?: ""
|
||||||
|
}
|
||||||
|
val excludedApps: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(EXCLUDED_APPS, profile)] ?: ""
|
||||||
}
|
}
|
||||||
val deploySshPort: Flow<String> = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" }
|
|
||||||
val excludedApps: Flow<String> = dataStore.data.map { it[EXCLUDED_APPS] ?: "" }
|
|
||||||
|
|
||||||
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
|
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
|
||||||
|
|
||||||
// ═══ Пароли и Управление ═══
|
// ═══ Пароли и Управление ═══
|
||||||
val connectionPassword: Flow<String> = dataStore.data.map {
|
val connectionPassword: Flow<String> = dataStore.data.map { prefs ->
|
||||||
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile)
|
||||||
}
|
}
|
||||||
val deployMainPassword: Flow<String> = dataStore.data.map {
|
val deployMainPassword: Flow<String> = dataStore.data.map { prefs ->
|
||||||
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile)
|
||||||
}
|
}
|
||||||
val deployAdminId: Flow<String> = dataStore.data.map {
|
val deployAdminId: Flow<String> = dataStore.data.map { prefs ->
|
||||||
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile)
|
||||||
}
|
}
|
||||||
val deployBotToken: Flow<String> = dataStore.data.map {
|
val deployBotToken: Flow<String> = dataStore.data.map { prefs ->
|
||||||
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
readSecret(prefs, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Proxy Mode ═══
|
// ═══ Proxy Mode ═══
|
||||||
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" }
|
val proxyMode: Flow<String> = dataStore.data.map { prefs ->
|
||||||
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" }
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 }
|
prefs[getProfileKey(PROXY_MODE, profile)] ?: "tun"
|
||||||
|
}
|
||||||
|
val proxyHost: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(PROXY_HOST, profile)] ?: "127.0.0.1"
|
||||||
|
}
|
||||||
|
val proxyPort: Flow<Int> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(PROXY_PORT, profile)] ?: 1080
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ Captcha Solve Mode ═══
|
// ═══ Captcha Solve Mode ═══
|
||||||
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" }
|
val captchaMode: Flow<String> = dataStore.data.map { prefs ->
|
||||||
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" }
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" }
|
prefs[getProfileKey(CAPTCHA_MODE, profile)] ?: "auto"
|
||||||
|
}
|
||||||
|
val captchaSolveMethod: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] ?: "auto"
|
||||||
|
}
|
||||||
|
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] ?: "auto"
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ VPN Exclusions Mode ═══
|
// ═══ VPN Exclusions Mode ═══
|
||||||
val isWhitelist: Flow<Boolean> = dataStore.data.map { it[IS_WHITELIST] ?: false }
|
val isWhitelist: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(IS_WHITELIST, profile)] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ Theme Mode ═══
|
// ═══ Theme Mode ═══
|
||||||
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
|
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
|
||||||
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false }
|
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) }
|
||||||
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
|
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
|
||||||
|
|
||||||
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
|
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
|
||||||
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
|
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
|
||||||
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
|
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
|
||||||
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS }
|
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: 24 }
|
||||||
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
|
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
|
||||||
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
|
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
|
||||||
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
|
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
|
||||||
@@ -215,6 +321,38 @@ class SettingsStore(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun saveActiveProfile(profile: Int) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[ACTIVE_PROFILE] = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveShowSystemApps(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[SHOW_SYSTEM_APPS] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveLoggingEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
prefs[LOGGING_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveWdttLink(link: String) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(WDTT_LINK, profile)] = link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveWdttLinkMode(enabled: Boolean) {
|
||||||
|
dataStore.edit { prefs ->
|
||||||
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(WDTT_LINK_MODE, profile)] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun save(
|
suspend fun save(
|
||||||
peer: String,
|
peer: String,
|
||||||
vkHashes: String,
|
vkHashes: String,
|
||||||
@@ -226,102 +364,115 @@ class SettingsStore(context: Context) {
|
|||||||
noDns: Boolean = false
|
noDns: Boolean = false
|
||||||
) {
|
) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[PEER] = peer
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs[VK_HASHES] = vkHashes
|
prefs[getProfileKey(PEER, profile)] = peer
|
||||||
prefs[SECONDARY_VK_HASH] = secondaryVkHash
|
prefs[getProfileKey(VK_HASHES, profile)] = vkHashes
|
||||||
prefs[WORKERS_PER_HASH] = workersPerHash
|
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] = secondaryVkHash
|
||||||
prefs[PROTOCOL] = protocol
|
prefs[getProfileKey(WORKERS_PER_HASH, profile)] = workersPerHash
|
||||||
prefs[LISTEN_PORT] = listenPort
|
prefs[getProfileKey(PROTOCOL, profile)] = protocol
|
||||||
prefs[SNI] = sni
|
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
|
||||||
prefs[NO_DNS] = noDns
|
prefs[getProfileKey(SNI, profile)] = sni
|
||||||
|
prefs[getProfileKey(NO_DNS, profile)] = noDns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveManualPortsEnabled(enabled: Boolean) {
|
suspend fun saveManualPortsEnabled(enabled: Boolean) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[MANUAL_PORTS_ENABLED] = enabled
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] = enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
|
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[SERVER_DTLS_PORT] = serverDtlsPort
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs[SERVER_WG_PORT] = serverWgPort
|
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] = serverDtlsPort
|
||||||
prefs[LISTEN_PORT] = listenPort
|
prefs[getProfileKey(SERVER_WG_PORT, profile)] = serverWgPort
|
||||||
|
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveUserAgent(ua: String) {
|
suspend fun saveUserAgent(ua: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[USER_AGENT] = ua
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(USER_AGENT, profile)] = ua
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
|
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[DEPLOY_IP] = ip
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs[DEPLOY_LOGIN] = login
|
prefs[getProfileKey(DEPLOY_IP, profile)] = ip
|
||||||
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass)
|
prefs[getProfileKey(DEPLOY_LOGIN, profile)] = login
|
||||||
prefs[DEPLOY_SSH_PORT] = sshPort
|
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass, profile)
|
||||||
|
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveExcludedApps(packages: String) {
|
suspend fun saveExcludedApps(packages: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[EXCLUDED_APPS] = packages
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveDetailedLogs(enabled: Boolean) {
|
suspend fun saveDetailedLogs(enabled: Boolean) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[DETAILED_LOGS] = enabled
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(DETAILED_LOGS, profile)] = enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Сохранение пароля подключения ═══
|
// ═══ Сохранение пароля подключения ═══
|
||||||
suspend fun saveConnectionPassword(password: String) {
|
suspend fun saveConnectionPassword(password: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Сохранение секретов деплоя ═══
|
// ═══ Сохранение секретов деплоя ═══
|
||||||
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
|
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass)
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId)
|
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass, profile)
|
||||||
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken)
|
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId, profile)
|
||||||
prefs[DEPLOY_SSH_PORT] = sshPort
|
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken, profile)
|
||||||
|
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Сохранение proxy mode ═══
|
// ═══ Сохранение proxy mode ═══
|
||||||
suspend fun saveProxyMode(mode: String, host: String, port: Int) {
|
suspend fun saveProxyMode(mode: String, host: String, port: Int) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[PROXY_MODE] = mode
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs[PROXY_HOST] = host
|
prefs[getProfileKey(PROXY_MODE, profile)] = mode
|
||||||
prefs[PROXY_PORT] = port
|
prefs[getProfileKey(PROXY_HOST, profile)] = host
|
||||||
|
prefs[getProfileKey(PROXY_PORT, profile)] = port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Сохранение режима обхода капчи ═══
|
// ═══ Сохранение режима обхода капчи ═══
|
||||||
suspend fun saveCaptchaMode(mode: String) {
|
suspend fun saveCaptchaMode(mode: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[CAPTCHA_MODE] = mode
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(CAPTCHA_MODE, profile)] = mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveCaptchaSolveMethod(method: String) {
|
suspend fun saveCaptchaSolveMethod(method: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[CAPTCHA_SOLVE_METHOD] = method
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveWbvCaptchaSolveMethod(method: String) {
|
suspend fun saveWbvCaptchaSolveMethod(method: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
if (prefs[CAPTCHA_MODE] == "wv") {
|
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] = method
|
||||||
prefs[CAPTCHA_SOLVE_METHOD] = method
|
if (prefs[getProfileKey(CAPTCHA_MODE, profile)] == "wv") {
|
||||||
|
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,47 +480,57 @@ class SettingsStore(context: Context) {
|
|||||||
// ═══ Сохранение режима списка (ЧС/БС) ═══
|
// ═══ Сохранение режима списка (ЧС/БС) ═══
|
||||||
suspend fun saveIsWhitelist(enabled: Boolean) {
|
suspend fun saveIsWhitelist(enabled: Boolean) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[IS_WHITELIST] = enabled
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
|
prefs[getProfileKey(IS_WHITELIST, profile)] = enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
|
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
|
||||||
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
|
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[EXCLUDED_APPS] = packages
|
val profile = prefs[ACTIVE_PROFILE] ?: 0
|
||||||
prefs[IS_WHITELIST] = isWhitelist
|
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
|
||||||
|
prefs[getProfileKey(IS_WHITELIST, profile)] = isWhitelist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun migrateSecretsToKeystore() {
|
private suspend fun migrateSecretsToKeystore() {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
|
for (profile in 0..2) {
|
||||||
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
|
prefs.migrateSecret(getProfileKey(DEPLOY_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_PASSWORD, profile))
|
||||||
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
|
prefs.migrateSecret(getProfileKey(CONNECTION_PASSWORD_ENCRYPTED, profile), getProfileKey(CONNECTION_PASSWORD, profile))
|
||||||
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
|
prefs.migrateSecret(getProfileKey(DEPLOY_MAIN_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_MAIN_PASSWORD, profile))
|
||||||
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
|
prefs.migrateSecret(getProfileKey(DEPLOY_ADMIN_ID_ENCRYPTED, profile), getProfileKey(DEPLOY_ADMIN_ID, profile))
|
||||||
|
prefs.migrateSecret(getProfileKey(DEPLOY_BOT_TOKEN_ENCRYPTED, profile), getProfileKey(DEPLOY_BOT_TOKEN, profile))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readSecret(
|
private fun readSecret(
|
||||||
prefs: Preferences,
|
prefs: Preferences,
|
||||||
encryptedKey: Preferences.Key<String>,
|
encryptedKey: Preferences.Key<String>,
|
||||||
legacyKey: Preferences.Key<String>
|
legacyKey: Preferences.Key<String>,
|
||||||
|
profile: Int
|
||||||
): String {
|
): String {
|
||||||
return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: ""
|
val profEncryptedKey = getProfileKey(encryptedKey, profile)
|
||||||
|
val profLegacyKey = getProfileKey(legacyKey, profile)
|
||||||
|
return secureStore.decrypt(prefs[profEncryptedKey]) ?: prefs[profLegacyKey] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MutablePreferences.putSecret(
|
private fun MutablePreferences.putSecret(
|
||||||
encryptedKey: Preferences.Key<String>,
|
encryptedKey: Preferences.Key<String>,
|
||||||
legacyKey: Preferences.Key<String>,
|
legacyKey: Preferences.Key<String>,
|
||||||
value: String
|
value: String,
|
||||||
|
profile: Int
|
||||||
) {
|
) {
|
||||||
|
val profEncryptedKey = getProfileKey(encryptedKey, profile)
|
||||||
|
val profLegacyKey = getProfileKey(legacyKey, profile)
|
||||||
if (value.isBlank()) {
|
if (value.isBlank()) {
|
||||||
remove(encryptedKey)
|
remove(profEncryptedKey)
|
||||||
remove(legacyKey)
|
remove(profLegacyKey)
|
||||||
} else {
|
} else {
|
||||||
this[encryptedKey] = secureStore.encrypt(value)
|
this[profEncryptedKey] = secureStore.encrypt(value)
|
||||||
remove(legacyKey)
|
remove(profLegacyKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ object TunnelManager {
|
|||||||
private var refusedCount = 0
|
private var refusedCount = 0
|
||||||
private var currentHashErrorCount = 0
|
private var currentHashErrorCount = 0
|
||||||
private var wrapAuthTimeoutCount = 0
|
private var wrapAuthTimeoutCount = 0
|
||||||
private var processStartedAtMs = 0L
|
var processStartedAtMs = 0L
|
||||||
private var lastActiveAtMs = 0L
|
private var lastActiveAtMs = 0L
|
||||||
private var activeHashIndex = 0 // 0: primary, 1: secondary
|
private var activeHashIndex = 0 // 0: primary, 1: secondary
|
||||||
private var currentParams: TunnelParams? = null
|
private var currentParams: TunnelParams? = null
|
||||||
@@ -49,6 +49,9 @@ object TunnelManager {
|
|||||||
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
|
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
|
||||||
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
|
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var isLoggingEnabled = true
|
||||||
|
|
||||||
val running = MutableStateFlow(false)
|
val running = MutableStateFlow(false)
|
||||||
val logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
val logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||||
val unreadErrorCount = MutableStateFlow(0)
|
val unreadErrorCount = MutableStateFlow(0)
|
||||||
@@ -56,7 +59,7 @@ object TunnelManager {
|
|||||||
val stats = MutableStateFlow("Ожидание данных...")
|
val stats = MutableStateFlow("Ожидание данных...")
|
||||||
val activeWorkers = MutableStateFlow(0)
|
val activeWorkers = MutableStateFlow(0)
|
||||||
|
|
||||||
val cooldownSeconds = MutableStateFlow(0)
|
val cooldownActive = MutableStateFlow(false)
|
||||||
private var cooldownJob: Job? = null
|
private var cooldownJob: Job? = null
|
||||||
|
|
||||||
fun clearUnreadErrors() {
|
fun clearUnreadErrors() {
|
||||||
@@ -75,6 +78,7 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
|
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
|
||||||
|
if (!isLoggingEnabled) return
|
||||||
if (isError) {
|
if (isError) {
|
||||||
val list = logs.value
|
val list = logs.value
|
||||||
if (list.none { it.key == key }) {
|
if (list.none { it.key == key }) {
|
||||||
@@ -152,13 +156,11 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hashCount = hashList.size.coerceIn(1, 3)
|
val hashCount = hashList.size.coerceIn(1, 3)
|
||||||
val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса
|
val totalWorkers = params.workersPerHash.coerceIn(1, 128)
|
||||||
|
|
||||||
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
|
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
|
||||||
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
|
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
|
||||||
|
|
||||||
|
|
||||||
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
|
|
||||||
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
|
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
|
||||||
val binaryFile = File(binaryPath)
|
val binaryFile = File(binaryPath)
|
||||||
|
|
||||||
@@ -182,15 +184,13 @@ object TunnelManager {
|
|||||||
cmd.add("-password")
|
cmd.add("-password")
|
||||||
cmd.add(params.connectionPassword)
|
cmd.add(params.connectionPassword)
|
||||||
|
|
||||||
// Captcha mode: wv или rjs
|
|
||||||
cmd.add("-captcha-mode")
|
cmd.add("-captcha-mode")
|
||||||
cmd.add(params.captchaMode)
|
cmd.add(params.captchaMode)
|
||||||
|
|
||||||
val pb = ProcessBuilder(cmd)
|
val pb = ProcessBuilder(cmd)
|
||||||
pb.directory(context.filesDir) // Устанавливаем рабочую директорию
|
pb.directory(context.filesDir)
|
||||||
pb.redirectErrorStream(true)
|
pb.redirectErrorStream(true)
|
||||||
|
|
||||||
// Set LD_LIBRARY_PATH
|
|
||||||
val env = pb.environment()
|
val env = pb.environment()
|
||||||
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
|
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
|
||||||
|
|
||||||
@@ -220,7 +220,6 @@ object TunnelManager {
|
|||||||
var lastResetTime = System.currentTimeMillis()
|
var lastResetTime = System.currentTimeMillis()
|
||||||
|
|
||||||
reader.forEachLine { line ->
|
reader.forEachLine { line ->
|
||||||
// Периодический сброс счетчиков ошибок (раз в 60 сек)
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (now - lastResetTime > 60000) {
|
if (now - lastResetTime > 60000) {
|
||||||
refusedCount = 0
|
refusedCount = 0
|
||||||
@@ -230,13 +229,11 @@ object TunnelManager {
|
|||||||
lastResetTime = now
|
lastResetTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...")
|
|
||||||
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
|
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
|
||||||
val lineTrim = msgPrefixReplaced.trim()
|
val lineTrim = msgPrefixReplaced.trim()
|
||||||
|
|
||||||
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
|
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
|
||||||
|
|
||||||
// 0. FATAL AUTH — мгновенная остановка
|
|
||||||
if (lineTrim.contains("FATAL_AUTH")) {
|
if (lineTrim.contains("FATAL_AUTH")) {
|
||||||
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
|
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
|
||||||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
|
lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
|
||||||
@@ -271,8 +268,6 @@ object TunnelManager {
|
|||||||
return@forEachLine
|
return@forEachLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
|
|
||||||
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
|
|
||||||
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
|
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
|
||||||
if (activeWorkers.value > 0) {
|
if (activeWorkers.value > 0) {
|
||||||
wrapAuthTimeoutCount = 0
|
wrapAuthTimeoutCount = 0
|
||||||
@@ -294,7 +289,6 @@ object TunnelManager {
|
|||||||
return@forEachLine
|
return@forEachLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима.
|
|
||||||
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
|
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
|
||||||
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
|
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
|
||||||
val parts = payload.split("|", limit = 3)
|
val parts = payload.split("|", limit = 3)
|
||||||
@@ -321,7 +315,6 @@ object TunnelManager {
|
|||||||
return@forEachLine
|
return@forEachLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker)
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
when {
|
when {
|
||||||
lineTrim.contains("Flood control", true) -> {
|
lineTrim.contains("Flood control", true) -> {
|
||||||
@@ -339,7 +332,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
|
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
|
||||||
// Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети
|
|
||||||
refusedCount++
|
refusedCount++
|
||||||
if (refusedCount >= 400) {
|
if (refusedCount >= 400) {
|
||||||
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
|
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
|
||||||
@@ -348,7 +340,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
|
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
|
||||||
currentHashErrorCount++
|
currentHashErrorCount++
|
||||||
// Нужно больше попыток, так как 1 воркер может спамить
|
|
||||||
if (currentHashErrorCount >= 10) {
|
if (currentHashErrorCount >= 10) {
|
||||||
handleHashError()
|
handleHashError()
|
||||||
return@forEachLine
|
return@forEachLine
|
||||||
@@ -357,7 +348,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Статистика (Обновляемая строка)
|
|
||||||
if (lineTrim.contains("[СТАТИСТИКА]")) {
|
if (lineTrim.contains("[СТАТИСТИКА]")) {
|
||||||
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
|
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
|
||||||
stats.value = msg
|
stats.value = msg
|
||||||
@@ -376,10 +366,7 @@ object TunnelManager {
|
|||||||
return@forEachLine
|
return@forEachLine
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Этапы подключения и Ошибки
|
|
||||||
when {
|
when {
|
||||||
|
|
||||||
// ═══ Авто-оркестратор капчи ═══
|
|
||||||
lineTrim.contains("[КАПЧА] AUTO:") -> {
|
lineTrim.contains("[КАПЧА] AUTO:") -> {
|
||||||
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
|
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
|
||||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||||
@@ -399,9 +386,7 @@ object TunnelManager {
|
|||||||
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
|
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══
|
|
||||||
lineTrim.contains("[КАПЧА] RJS:") -> {
|
lineTrim.contains("[КАПЧА] RJS:") -> {
|
||||||
// Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...)
|
|
||||||
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
|
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
|
||||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||||
|
|
||||||
@@ -417,15 +402,14 @@ object TunnelManager {
|
|||||||
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
|
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══
|
|
||||||
lineTrim.contains("[КАПЧА] WBV:") -> {
|
lineTrim.contains("[КАПЧА] WBV:") -> {
|
||||||
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
|
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
|
||||||
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
|
||||||
|
|
||||||
val isErr = text.contains("Ошибка")
|
val isErr = text.contains("Ошибка")
|
||||||
val stableKey = when {
|
val stableKey = when {
|
||||||
text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV)
|
text.contains("Запрос") -> "captcha_wv_step_2"
|
||||||
text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением)
|
text.contains("Токен") -> "captcha_wv_step_5"
|
||||||
isErr -> "captcha_wv_err"
|
isErr -> "captcha_wv_err"
|
||||||
else -> "captcha_wv_go_other"
|
else -> "captcha_wv_go_other"
|
||||||
}
|
}
|
||||||
@@ -462,9 +446,7 @@ object TunnelManager {
|
|||||||
lineTrim.contains("Активна ✓") ->
|
lineTrim.contains("Активна ✓") ->
|
||||||
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
|
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
|
||||||
|
|
||||||
// Ошибки (в конец)
|
|
||||||
isError -> {
|
isError -> {
|
||||||
// Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки)
|
|
||||||
val errorKey = when {
|
val errorKey = when {
|
||||||
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
|
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
|
||||||
lineTrim.contains("connection refused") -> "err_conn_refused"
|
lineTrim.contains("connection refused") -> "err_conn_refused"
|
||||||
@@ -482,7 +464,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Обработка конфига (Скрываем от пользователя)
|
|
||||||
if (line.contains("╔") && line.contains("WireGuard")) {
|
if (line.contains("╔") && line.contains("WireGuard")) {
|
||||||
collectingConfig = true
|
collectingConfig = true
|
||||||
configBuilder.clear()
|
configBuilder.clear()
|
||||||
@@ -528,7 +509,7 @@ object TunnelManager {
|
|||||||
val context = lastContext ?: return
|
val context = lastContext ?: return
|
||||||
|
|
||||||
currentHashErrorCount = 0
|
currentHashErrorCount = 0
|
||||||
forceRegenerateUA = true // Перегенерируем UA при следующих ошибках
|
forceRegenerateUA = true
|
||||||
|
|
||||||
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
|
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
|
||||||
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
|
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
|
||||||
@@ -541,18 +522,14 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== WATCHDOG ====================
|
|
||||||
// Проверяет, жив ли Go-процесс. Если умер — перезапускает.
|
|
||||||
// Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби).
|
|
||||||
private fun startWatchdog(context: Context, params: TunnelParams) {
|
private fun startWatchdog(context: Context, params: TunnelParams) {
|
||||||
watchdogJob?.cancel()
|
watchdogJob?.cancel()
|
||||||
watchdogJob = scope.launch {
|
watchdogJob = scope.launch {
|
||||||
var zeroWorkersSince = 0L
|
var zeroWorkersSince = 0L
|
||||||
delay(10_000) // Даём 10 сек на старт
|
delay(10_000)
|
||||||
while (isActive && running.value) {
|
while (isActive && running.value) {
|
||||||
val proc = process
|
val proc = process
|
||||||
if (proc == null || !proc.isAlive) {
|
if (proc == null || !proc.isAlive) {
|
||||||
// Go-процесс мёртв!
|
|
||||||
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
|
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
|
||||||
activeWorkers.value = 0
|
activeWorkers.value = 0
|
||||||
forceRegenerateUA = true
|
forceRegenerateUA = true
|
||||||
@@ -561,10 +538,9 @@ object TunnelManager {
|
|||||||
if (running.value) {
|
if (running.value) {
|
||||||
start(context, params, isSwitching = true)
|
start(context, params, isSwitching = true)
|
||||||
}
|
}
|
||||||
return@launch // startWatchdog будет перезапущен из start()
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Детекция зомби: процесс жив, но 0 воркеров
|
|
||||||
val workers = activeWorkers.value
|
val workers = activeWorkers.value
|
||||||
if (workers <= 0) {
|
if (workers <= 0) {
|
||||||
if (zeroWorkersSince == 0L) {
|
if (zeroWorkersSince == 0L) {
|
||||||
@@ -601,7 +577,7 @@ object TunnelManager {
|
|||||||
val params = currentParams ?: return
|
val params = currentParams ?: return
|
||||||
val context = lastContext ?: return
|
val context = lastContext ?: return
|
||||||
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
|
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
|
||||||
killProcess() // Только убиваем процесс, running не трогаем!
|
killProcess()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(1500)
|
delay(1500)
|
||||||
start(context, params, isSwitching = true)
|
start(context, params, isSwitching = true)
|
||||||
@@ -610,7 +586,7 @@ object TunnelManager {
|
|||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
if (!running.value) return
|
if (!running.value) return
|
||||||
killProcess() // Не ставим running=false, чтоб сервис не умер
|
killProcess()
|
||||||
activeWorkers.value = 0
|
activeWorkers.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,7 +598,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Убивает процесс без изменения running
|
|
||||||
private fun killProcess() {
|
private fun killProcess() {
|
||||||
watchdogJob?.cancel()
|
watchdogJob?.cancel()
|
||||||
readerJob?.cancel()
|
readerJob?.cancel()
|
||||||
@@ -630,7 +605,6 @@ object TunnelManager {
|
|||||||
process = null
|
process = null
|
||||||
if (proc != null) {
|
if (proc != null) {
|
||||||
try { proc.destroy() } catch (_: Exception) {}
|
try { proc.destroy() } catch (_: Exception) {}
|
||||||
// Даём 500мс на graceful shutdown
|
|
||||||
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
|
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
|
||||||
if (proc.isAlive) {
|
if (proc.isAlive) {
|
||||||
try { proc.destroyForcibly() } catch (_: Exception) {}
|
try { proc.destroyForcibly() } catch (_: Exception) {}
|
||||||
@@ -644,10 +618,6 @@ object TunnelManager {
|
|||||||
running.value = false
|
running.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log(message: String) {
|
|
||||||
updateLog("internal_${message.hashCode()}", message, 50, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
scope.launch(Dispatchers.Main) {
|
scope.launch(Dispatchers.Main) {
|
||||||
wgHelper?.stopTunnel()
|
wgHelper?.stopTunnel()
|
||||||
@@ -659,9 +629,7 @@ object TunnelManager {
|
|||||||
ManlCaptchaWebViewManager.cancelCaptcha()
|
ManlCaptchaWebViewManager.cancelCaptcha()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suspend-версия: гарантирует что процесс мёртв и порт свободен
|
|
||||||
suspend fun stopAndWait() {
|
suspend fun stopAndWait() {
|
||||||
// Сначала останавливаем WireGuard и ждём завершения
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
wgHelper?.stopTunnel()
|
wgHelper?.stopTunnel()
|
||||||
}
|
}
|
||||||
@@ -671,11 +639,10 @@ object TunnelManager {
|
|||||||
activeWorkers.value = 0
|
activeWorkers.value = 0
|
||||||
currentParams = null
|
currentParams = null
|
||||||
ManlCaptchaWebViewManager.cancelCaptcha()
|
ManlCaptchaWebViewManager.cancelCaptcha()
|
||||||
// Ждём освобождения порта 9000 (до 3 секунд)
|
|
||||||
repeat(30) {
|
repeat(30) {
|
||||||
try {
|
try {
|
||||||
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
|
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
|
||||||
return@withContext // Порт свободен!
|
return@withContext
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
@@ -691,15 +658,6 @@ object TunnelManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== CAPTCHA SOLVER (WebView Mode) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Вызывается при получении CAPTCHA_SOLVE от Go-процесса.
|
|
||||||
* auto: одна короткая скрытая попытка для Go-оркестратора.
|
|
||||||
* manual: сразу видимый WebView.
|
|
||||||
* selected: старое поведение из UI, когда пользователь сам выбрал режим.
|
|
||||||
* Результат ВСЕГДА отправляется обратно в Go через writeCaptchaResult.
|
|
||||||
*/
|
|
||||||
private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
|
private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
|
||||||
val ctx = lastContext ?: run {
|
val ctx = lastContext ?: run {
|
||||||
writeCaptchaResult("error:context is null")
|
writeCaptchaResult("error:context is null")
|
||||||
@@ -743,7 +701,6 @@ object TunnelManager {
|
|||||||
writeCaptchaResult("error:$errorMsg")
|
writeCaptchaResult("error:$errorMsg")
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebView уничтожен в finally блоке соответствующего менеджера.
|
|
||||||
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
|
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,9 +742,6 @@ object TunnelManager {
|
|||||||
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Записывает результат решения капчи в stdin Go-процесса.
|
|
||||||
*/
|
|
||||||
private fun writeCaptchaResult(result: String) {
|
private fun writeCaptchaResult(result: String) {
|
||||||
val proc = process
|
val proc = process
|
||||||
if (proc == null || !proc.isAlive) return
|
if (proc == null || !proc.isAlive) return
|
||||||
@@ -802,17 +756,17 @@ object TunnelManager {
|
|||||||
|
|
||||||
fun clearLogs() {
|
fun clearLogs() {
|
||||||
logs.value = emptyList()
|
logs.value = emptyList()
|
||||||
|
if (!running.value) {
|
||||||
activeWorkers.value = 0
|
activeWorkers.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startCooldown(seconds: Int) {
|
|
||||||
cooldownJob?.cancel()
|
|
||||||
cooldownSeconds.value = seconds
|
|
||||||
cooldownJob = scope.launch(Dispatchers.Main) {
|
|
||||||
while (cooldownSeconds.value > 0) {
|
|
||||||
delay(1000)
|
|
||||||
cooldownSeconds.update { it - 1 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startCooldown(millis: Long) {
|
||||||
|
cooldownJob?.cancel()
|
||||||
|
cooldownActive.value = true
|
||||||
|
cooldownJob = scope.launch(Dispatchers.Main) {
|
||||||
|
delay(millis)
|
||||||
|
cooldownActive.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +785,6 @@ data class TunnelParams(
|
|||||||
val sni: String = "",
|
val sni: String = "",
|
||||||
val connectionPassword: String = "",
|
val connectionPassword: String = "",
|
||||||
val protocol: String = "udp",
|
val protocol: String = "udp",
|
||||||
val captchaMode: String = "auto", // "auto", "wv" или "rjs"
|
val captchaMode: String = "auto",
|
||||||
val captchaSolveMethod: String = "auto" // "manual" или "auto"
|
val captchaSolveMethod: String = "auto"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -283,6 +283,15 @@ class TunnelService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if (TunnelManager.running.value && !isTunnelPaused) {
|
||||||
|
val helper = WireGuardHelper(applicationContext)
|
||||||
|
val startupWindow = System.currentTimeMillis() - TunnelManager.processStartedAtMs < 6000
|
||||||
|
if (!startupWindow && !helper.isTunnelUp()) {
|
||||||
|
Log.w("TunnelService", "Обнаружена пропажа или замена VPN-интерфейса! Экстренное выключение туннеля.")
|
||||||
|
stopTunnel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isTunnelPaused) {
|
if (!isTunnelPaused) {
|
||||||
updateNotification(buildTunnelNotificationText())
|
updateNotification(buildTunnelNotificationText())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.wdtt.client
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class VpnWidgetProvider : AppWidgetProvider() {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_WIDGET_TOGGLE = "com.wdtt.client.ACTION_WIDGET_TOGGLE"
|
||||||
|
|
||||||
|
fun updateAllWidgets(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val thisWidget = ComponentName(context, VpnWidgetProvider::class.java)
|
||||||
|
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
|
||||||
|
if (appWidgetIds.isNotEmpty()) {
|
||||||
|
val intent = Intent(context, VpnWidgetProvider::class.java).apply {
|
||||||
|
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||||
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
|
||||||
|
}
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
|
val running = TunnelManager.running.value
|
||||||
|
for (appWidgetId in appWidgetIds) {
|
||||||
|
updateWidgetState(context, appWidgetManager, appWidgetId, running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
if (intent.action == ACTION_WIDGET_TOGGLE) {
|
||||||
|
runCatching {
|
||||||
|
if (TunnelManager.running.value) {
|
||||||
|
// Останавливаем туннель
|
||||||
|
val stopIntent = Intent(context, TunnelService::class.java).apply { action = "STOP" }
|
||||||
|
context.startService(stopIntent)
|
||||||
|
updateAllWidgets(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VpnService.prepare(context) != null) {
|
||||||
|
Toast.makeText(context, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
|
||||||
|
openMainActivity(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск туннеля в фоне
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val startIntent = buildStartIntent(context)
|
||||||
|
if (startIntent == null) {
|
||||||
|
Toast.makeText(context, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
|
||||||
|
openMainActivity(context)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
|
context.startForegroundService(startIntent)
|
||||||
|
} else {
|
||||||
|
context.startService(startIntent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("VpnWidget", "Failed to start tunnel from widget", e)
|
||||||
|
Toast.makeText(context, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("VpnWidget", "Error handling widget click", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWidgetState(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetId: Int,
|
||||||
|
running: Boolean
|
||||||
|
) {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.vpn_widget)
|
||||||
|
|
||||||
|
// Обновляем текст статуса и неоновую иконку кнопки
|
||||||
|
if (running) {
|
||||||
|
views.setTextViewText(R.id.widget_status, "Подключено")
|
||||||
|
views.setTextColor(R.id.widget_status, 0xFF00E5FF.toInt()) // Неоновый голубой
|
||||||
|
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_active)
|
||||||
|
} else {
|
||||||
|
views.setTextViewText(R.id.widget_status, "Отключено")
|
||||||
|
views.setTextColor(R.id.widget_status, 0xFF888888.toInt()) // Матовый серый
|
||||||
|
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_inactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клик по всей карточке открывает приложение
|
||||||
|
val openIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
val openPendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
appWidgetId,
|
||||||
|
openIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
|
||||||
|
|
||||||
|
// Клик по кнопке запускает/останавливает VPN
|
||||||
|
val toggleIntent = Intent(context, VpnWidgetProvider::class.java).apply {
|
||||||
|
action = ACTION_WIDGET_TOGGLE
|
||||||
|
}
|
||||||
|
val togglePendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
appWidgetId + 1000,
|
||||||
|
toggleIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.widget_toggle_btn, togglePendingIntent)
|
||||||
|
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildStartIntent(context: Context): Intent? {
|
||||||
|
val store = SettingsStore(context.applicationContext)
|
||||||
|
val basePeer = store.peer.first()
|
||||||
|
val hashes = store.vkHashes.first()
|
||||||
|
val password = store.connectionPassword.first()
|
||||||
|
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
|
||||||
|
|
||||||
|
val manualPortsEnabled = store.manualPortsEnabled.first()
|
||||||
|
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
|
||||||
|
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
|
||||||
|
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
|
||||||
|
|
||||||
|
return Intent(context, TunnelService::class.java).apply {
|
||||||
|
action = "START"
|
||||||
|
putExtra("peer", peerWithPort)
|
||||||
|
putExtra("vk_hashes", hashes)
|
||||||
|
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
|
||||||
|
putExtra("workers_per_hash", store.workersPerHash.first())
|
||||||
|
putExtra("port", localPort)
|
||||||
|
putExtra("sni", store.sni.first())
|
||||||
|
putExtra("connection_password", password)
|
||||||
|
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
|
||||||
|
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openMainActivity(context: Context) {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
200,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
pendingIntent.send()
|
||||||
|
}.onFailure {
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeCaptchaMode(mode: String?): String {
|
||||||
|
return when (mode?.lowercase()) {
|
||||||
|
"auto" -> "auto"
|
||||||
|
"rjs" -> "rjs"
|
||||||
|
"wv" -> "wv"
|
||||||
|
else -> "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@ package com.wdtt.client
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import com.wireguard.android.backend.GoBackend
|
import com.wireguard.android.backend.GoBackend
|
||||||
|
import com.wireguard.android.backend.Tunnel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class WdttApplication : Application() {
|
class WdttApplication : Application() {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -14,6 +20,43 @@ class WdttApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DeployManager.init(this)
|
DeployManager.init(this)
|
||||||
|
|
||||||
|
// Очищаем фантомный VPN при холодном старте приложения (например, после перезагрузки телефона).
|
||||||
|
// Если телефон перезагрузился, система Android пытается сама восстановить VpnService,
|
||||||
|
// что приводит к фантомному ключу без интернета. Этот код мгновенно сбрасывает статус в DOWN.
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
|
runCatching {
|
||||||
|
val backend = getBackend(this@WdttApplication)
|
||||||
|
val tunnel = WireGuardHelper.WgTunnel()
|
||||||
|
backend.setState(tunnel, Tunnel.State.DOWN, null)
|
||||||
|
Log.d("WdttApp", "Успешно очищен фантомный VPN при холодном старте")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w("WdttApp", "Не удалось очистить фантомный VPN: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реактивно обновляем все виджеты на домашнем экране при изменении состояния туннеля
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
|
||||||
|
try {
|
||||||
|
TunnelManager.running.collect {
|
||||||
|
VpnWidgetProvider.updateAllWidgets(this@WdttApplication)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("WdttApp", "Не удалось обновить виджеты: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реактивно отслеживаем флаг логирования
|
||||||
|
val settingsStore = SettingsStore(this)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
|
||||||
|
try {
|
||||||
|
settingsStore.loggingEnabled.collect { enabled ->
|
||||||
|
TunnelManager.isLoggingEnabled = enabled
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("WdttApp", "Не удалось отслеживать флаг логирования: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBackend(context: Context): GoBackend {
|
fun getBackend(context: Context): GoBackend {
|
||||||
|
|||||||
@@ -137,6 +137,15 @@ class WireGuardHelper(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun isTunnelUp(): Boolean = wgMutex.withLock {
|
||||||
|
val current = sharedTunnel ?: return false
|
||||||
|
return try {
|
||||||
|
backend.getState(current) == Tunnel.State.UP
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun stopTunnel() = wgMutex.withLock {
|
suspend fun stopTunnel() = wgMutex.withLock {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ fun AppSectionCard(
|
|||||||
color = appSectionCardColor(),
|
color = appSectionCardColor(),
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
border = BorderStroke(1.dp, appSectionCardBorderColor()),
|
border = BorderStroke(1.dp, appSectionCardBorderColor()),
|
||||||
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp,
|
shadowElevation = 0.dp,
|
||||||
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp,
|
tonalElevation = 0.dp,
|
||||||
modifier = modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ fun DeployTab() {
|
|||||||
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
|
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
|
||||||
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
|
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp }
|
LaunchedEffect(savedIp) { ip = savedIp }
|
||||||
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin }
|
LaunchedEffect(savedLogin) { login = savedLogin }
|
||||||
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword }
|
LaunchedEffect(savedPassword) { password = savedPassword }
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = deployProgress,
|
targetValue = deployProgress,
|
||||||
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ import androidx.compose.runtime.Stable
|
|||||||
data class AppItem(
|
data class AppItem(
|
||||||
val name: String,
|
val name: String,
|
||||||
val packageName: String,
|
val packageName: String,
|
||||||
val icon: ImageBitmap?
|
val icon: ImageBitmap?,
|
||||||
|
val isSystem: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
object AppCache {
|
object AppCache {
|
||||||
@@ -61,6 +62,8 @@ fun ExceptionsTab() {
|
|||||||
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
|
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val showSystemAppsOpt by settingsStore.showSystemApps.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
|
||||||
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
|
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
// Load Apps
|
// Load Apps
|
||||||
@@ -76,10 +79,12 @@ fun ExceptionsTab() {
|
|||||||
if (app.packageName != context.packageName &&
|
if (app.packageName != context.packageName &&
|
||||||
!app.packageName.contains("vkontakte") &&
|
!app.packageName.contains("vkontakte") &&
|
||||||
!app.packageName.contains("vk.calls")) {
|
!app.packageName.contains("vk.calls")) {
|
||||||
|
val isSys = (app.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
|
||||||
list.add(AppItem(
|
list.add(AppItem(
|
||||||
name = app.loadLabel(pm).toString(),
|
name = app.loadLabel(pm).toString(),
|
||||||
packageName = app.packageName,
|
packageName = app.packageName,
|
||||||
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap()
|
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap(),
|
||||||
|
isSystem = isSys
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,8 +96,17 @@ fun ExceptionsTab() {
|
|||||||
|
|
||||||
val filteredApps by remember {
|
val filteredApps by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
if (searchQuery.isBlank()) appsList
|
val showSystemApps = showSystemAppsOpt ?: true
|
||||||
else appsList.filter {
|
val list = if (showSystemApps) {
|
||||||
|
appsList
|
||||||
|
} else {
|
||||||
|
appsList.filter {
|
||||||
|
!it.isSystem || it.packageName == "com.google.android.youtube" || it.packageName == "com.android.vending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.isBlank()) list
|
||||||
|
else list.filter {
|
||||||
it.name.contains(searchQuery, ignoreCase = true) ||
|
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
it.packageName.contains(searchQuery, ignoreCase = true)
|
it.packageName.contains(searchQuery, ignoreCase = true)
|
||||||
}
|
}
|
||||||
@@ -174,10 +188,37 @@ fun ExceptionsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Системные приложения",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = showSystemAppsOpt ?: true,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
scope.launch {
|
||||||
|
settingsStore.saveShowSystemApps(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List
|
// List
|
||||||
if (isLoading) {
|
if (isLoading || showSystemAppsOpt == null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ import android.os.Build
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FloatingToolbar(
|
fun FloatingToolbar(
|
||||||
|
activeProfile: Int,
|
||||||
|
onActiveProfileChange: (Int) -> Unit,
|
||||||
currentTheme: String,
|
currentTheme: String,
|
||||||
onThemeChange: (String) -> Unit,
|
onThemeChange: (String) -> Unit,
|
||||||
isDynamicColor: Boolean,
|
isDynamicColor: Boolean,
|
||||||
@@ -55,6 +60,9 @@ fun FloatingToolbar(
|
|||||||
with(density) { configuration.screenWidthDp.dp.toPx() }
|
with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var parentWidthPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
var parentHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
|
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
|
||||||
var isRightSide by rememberSaveable { mutableStateOf(true) }
|
var isRightSide by rememberSaveable { mutableStateOf(true) }
|
||||||
var isExpanded by rememberSaveable { mutableStateOf(false) }
|
var isExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -76,12 +84,16 @@ fun FloatingToolbar(
|
|||||||
} else {
|
} else {
|
||||||
effectiveTabHeightPx
|
effectiveTabHeightPx
|
||||||
}
|
}
|
||||||
val minOffsetY = safeTopPx + edgePaddingPx
|
|
||||||
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx)
|
|
||||||
.coerceAtLeast(minOffsetY)
|
|
||||||
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
|
|
||||||
|
|
||||||
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f
|
val currentParentHeight = if (parentHeightPx > 0f) parentHeightPx else screenHeightPx
|
||||||
|
val currentParentWidth = if (parentWidthPx > 0f) parentWidthPx else screenWidthPx
|
||||||
|
|
||||||
|
val minOffsetY = safeTopPx + edgePaddingPx
|
||||||
|
val maxOffsetY = (currentParentHeight - safeBottomPx - floatingHeightPx - edgePaddingPx)
|
||||||
|
.coerceAtLeast(minOffsetY)
|
||||||
|
val defaultOffsetY = (currentParentHeight * 0.24f).coerceIn(minOffsetY, maxOffsetY)
|
||||||
|
|
||||||
|
val targetXPx = if (isRightSide) currentParentWidth - tabWidthPx else 0f
|
||||||
|
|
||||||
val animatedTabXPx by animateFloatAsState(
|
val animatedTabXPx by animateFloatAsState(
|
||||||
targetValue = targetXPx,
|
targetValue = targetXPx,
|
||||||
@@ -93,7 +105,14 @@ fun FloatingToolbar(
|
|||||||
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
|
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
parentWidthPx = coordinates.size.width.toFloat()
|
||||||
|
parentHeightPx = coordinates.size.height.toFloat()
|
||||||
|
}
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { isExpanded = !isExpanded },
|
onClick = { isExpanded = !isExpanded },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -114,16 +133,16 @@ fun FloatingToolbar(
|
|||||||
else
|
else
|
||||||
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
|
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
|
||||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
|
||||||
shadowElevation = 6.dp,
|
shadowElevation = 0.dp,
|
||||||
tonalElevation = 4.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(tabWidthDp, tabHeightDp),
|
modifier = Modifier.size(tabWidthDp, tabHeightDp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_palette),
|
imageVector = Icons.Filled.Settings,
|
||||||
contentDescription = "Тема",
|
contentDescription = "Настройки",
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
@@ -151,13 +170,54 @@ fun FloatingToolbar(
|
|||||||
},
|
},
|
||||||
shape = RoundedCornerShape(32.dp),
|
shape = RoundedCornerShape(32.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shadowElevation = 8.dp,
|
shadowElevation = 0.dp,
|
||||||
tonalElevation = 4.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
|
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
"Профиль",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf(0, 1, 2).forEach { profile ->
|
||||||
|
val selected = profile == activeProfile
|
||||||
|
Surface(
|
||||||
|
onClick = { onActiveProfileChange(profile) },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Пр. $profile",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Тема",
|
"Тема",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.wdtt.client.ui
|
package com.wdtt.client.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -156,7 +158,10 @@ private fun openUrlInBrowser(context: Context, url: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoTab() {
|
fun InfoTab(
|
||||||
|
actionsExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) },
|
||||||
|
projectExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
@@ -165,8 +170,8 @@ fun InfoTab() {
|
|||||||
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
|
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
|
||||||
var showHelpDialog by remember { mutableStateOf(false) }
|
var showHelpDialog by remember { mutableStateOf(false) }
|
||||||
var showDonateDialog by remember { mutableStateOf(false) }
|
var showDonateDialog by remember { mutableStateOf(false) }
|
||||||
var actionsExpanded by rememberSaveable { mutableStateOf(true) }
|
var actionsExpanded by actionsExpandedState
|
||||||
var projectExpanded by rememberSaveable { mutableStateOf(true) }
|
var projectExpanded by projectExpandedState
|
||||||
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
|
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
|
||||||
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
|
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
|
||||||
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
|
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
|
||||||
|
|||||||
@@ -29,11 +29,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.wdtt.client.LogEntry
|
import com.wdtt.client.LogEntry
|
||||||
import com.wdtt.client.TunnelManager
|
import com.wdtt.client.TunnelManager
|
||||||
import com.wdtt.client.WDTTColors
|
import com.wdtt.client.WDTTColors
|
||||||
|
import com.wdtt.client.SettingsStore
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LogsTab() {
|
fun LogsTab() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
|
val loggingEnabled by settingsStore.loggingEnabled.collectAsStateWithLifecycle(initialValue = true)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
|
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
@@ -65,6 +70,38 @@ fun LogsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Карточка-выключатель логирования
|
||||||
|
AppSectionCard(
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Активное логирование",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = loggingEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
scope.launch {
|
||||||
|
settingsStore.saveLoggingEnabled(enabled)
|
||||||
|
if (!enabled) {
|
||||||
|
TunnelManager.clearLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Logs container — адаптивный к теме
|
// Logs container — адаптивный к теме
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
|
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.wdtt.client.ui
|
package com.wdtt.client.ui
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -83,14 +86,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
|
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
|
||||||
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000)
|
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000)
|
||||||
|
|
||||||
|
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
|
||||||
|
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val wdttLink by settingsStore.wdttLink.collectAsStateWithLifecycle(initialValue = "")
|
||||||
|
|
||||||
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
|
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle()
|
val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
|
||||||
var wasRunning by remember { mutableStateOf(false) }
|
var wasRunning by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(tunnelRunning) {
|
LaunchedEffect(tunnelRunning) {
|
||||||
if (wasRunning && !tunnelRunning) {
|
if (wasRunning && !tunnelRunning) {
|
||||||
TunnelManager.startCooldown(5)
|
TunnelManager.startCooldown(1500L)
|
||||||
}
|
}
|
||||||
wasRunning = tunnelRunning
|
wasRunning = tunnelRunning
|
||||||
}
|
}
|
||||||
@@ -112,7 +119,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
|
|
||||||
val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) }
|
val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) }
|
||||||
val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() }
|
val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() }
|
||||||
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.size }
|
val parsedLinkHashes = remember(wdttLink) {
|
||||||
|
if (wdttLink.trim().startsWith("wdtt://")) {
|
||||||
|
val clean = wdttLink.trim().removePrefix("wdtt://")
|
||||||
|
val parts = clean.split(":")
|
||||||
|
if (parts.size >= 6) {
|
||||||
|
parts[5].split(",").filter { stripVkUrlStatic(it).isNotBlank() }
|
||||||
|
} else emptyList()
|
||||||
|
} else emptyList()
|
||||||
|
}
|
||||||
|
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4, wdttLinkMode, parsedLinkHashes) {
|
||||||
|
if (wdttLinkMode) parsedLinkHashes.size else uniqueHashes.size
|
||||||
|
}
|
||||||
val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
|
val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
|
||||||
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
|
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
|
||||||
var portInput by rememberSaveable { mutableStateOf("9000") }
|
var portInput by rememberSaveable { mutableStateOf("9000") }
|
||||||
@@ -156,7 +174,7 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
.joinToString(",")
|
.joinToString(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(activeProfile) {
|
||||||
val peer = settingsStore.peer.first()
|
val peer = settingsStore.peer.first()
|
||||||
val hashes = settingsStore.vkHashes.first()
|
val hashes = settingsStore.vkHashes.first()
|
||||||
val workers = settingsStore.workersPerHash.first()
|
val workers = settingsStore.workersPerHash.first()
|
||||||
@@ -238,7 +256,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
|
|
||||||
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
|
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
|
||||||
val isHashesValid = combinedHashes.isNotBlank()
|
val isHashesValid = combinedHashes.isNotBlank()
|
||||||
val isValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
|
val isLinkValid = wdttLink.trim().startsWith("wdtt://") && wdttLink.trim().split(":").size >= 6 && wdttLink.trim().split(":")[5].isNotBlank()
|
||||||
|
val isManualValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
|
||||||
|
val isValid = if (wdttLinkMode) isLinkValid else isManualValid
|
||||||
val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
|
val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
|
||||||
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
|
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
|
||||||
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
|
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
|
||||||
@@ -255,15 +275,37 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
settingsStore.saveCaptchaMode(effectiveCaptchaMode)
|
settingsStore.saveCaptchaMode(effectiveCaptchaMode)
|
||||||
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod)
|
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var finalPeer = "$peerInput:$effectiveServerDtlsPort"
|
||||||
|
var finalHashes = combinedHashes
|
||||||
|
var finalLocalPort = effectiveLocalPort
|
||||||
|
var finalPassword = savedConnectionPassword
|
||||||
|
|
||||||
|
if (wdttLinkMode && wdttLink.trim().startsWith("wdtt://")) {
|
||||||
|
val clean = wdttLink.trim().removePrefix("wdtt://")
|
||||||
|
val parts = clean.split(":")
|
||||||
|
if (parts.size >= 5) {
|
||||||
|
val ip = parts[0]
|
||||||
|
val dtls = parts[1].toIntOrNull() ?: 56000
|
||||||
|
finalLocalPort = parts[3].toIntOrNull() ?: 9000
|
||||||
|
finalPassword = parts[4]
|
||||||
|
val hash = if (parts.size >= 6) parts[5] else ""
|
||||||
|
|
||||||
|
finalPeer = "$ip:$dtls"
|
||||||
|
val rawHash = stripVkUrlStatic(hash)
|
||||||
|
finalHashes = if (rawHash.isNotBlank()) rawHash else normalizeHashes(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val intent = Intent(context, TunnelService::class.java).apply {
|
val intent = Intent(context, TunnelService::class.java).apply {
|
||||||
action = "START"
|
action = "START"
|
||||||
putExtra("peer", "$peerInput:$effectiveServerDtlsPort")
|
putExtra("peer", finalPeer)
|
||||||
putExtra("vk_hashes", combinedHashes)
|
putExtra("vk_hashes", finalHashes)
|
||||||
putExtra("secondary_vk_hash", "")
|
putExtra("secondary_vk_hash", "")
|
||||||
putExtra("workers_per_hash", workersInput.toInt())
|
putExtra("workers_per_hash", workersInput.toInt())
|
||||||
putExtra("port", effectiveLocalPort)
|
putExtra("port", finalLocalPort)
|
||||||
putExtra("sni", sniInput)
|
putExtra("sni", sniInput)
|
||||||
putExtra("connection_password", savedConnectionPassword)
|
putExtra("connection_password", finalPassword)
|
||||||
putExtra("captcha_mode", effectiveCaptchaMode)
|
putExtra("captcha_mode", effectiveCaptchaMode)
|
||||||
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
|
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
|
||||||
}
|
}
|
||||||
@@ -342,6 +384,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
if (!wdttLinkMode) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
// ═══ Заголовок раздела ═══
|
// ═══ Заголовок раздела ═══
|
||||||
Text(
|
Text(
|
||||||
"Настройки туннеля",
|
"Настройки туннеля",
|
||||||
@@ -400,6 +445,8 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ Мощность + Капча ═══
|
// ═══ Мощность + Капча ═══
|
||||||
AppSectionCard(
|
AppSectionCard(
|
||||||
@@ -575,17 +622,70 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// — Режим ссылки —
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Режим ссылки",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = wdttLinkMode,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
scope.launch {
|
||||||
|
settingsStore.saveWdttLinkMode(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wdttLinkMode) {
|
||||||
|
Column {
|
||||||
|
var linkText by remember(wdttLink) { mutableStateOf(wdttLink) }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = linkText,
|
||||||
|
onValueChange = {
|
||||||
|
linkText = it.trim()
|
||||||
|
scope.launch { settingsStore.saveWdttLink(it.trim()) }
|
||||||
|
},
|
||||||
|
label = { Text("Ссылка wdtt://") },
|
||||||
|
placeholder = { Text("Ссылка wdtt://") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Кнопки: Секреты + Подключить ═══
|
// ═══ Кнопки: Секреты + Подключить ═══
|
||||||
val tunnelSecretsMissing = savedConnectionPassword.isBlank()
|
val tunnelSecretsMissing = savedConnectionPassword.isBlank()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
if (!wdttLinkMode) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showSecretsDialog = true },
|
onClick = { showSecretsDialog = true },
|
||||||
modifier = Modifier.height(52.dp),
|
modifier = Modifier.weight(1f).height(52.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
|
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
|
||||||
@@ -598,7 +698,8 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
) {
|
) {
|
||||||
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Секреты", fontWeight = FontWeight.SemiBold)
|
Text("Секреты", fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val buttonColor by animateColorAsState(
|
val buttonColor by animateColorAsState(
|
||||||
@@ -617,8 +718,10 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
requestVpnAndStart()
|
requestVpnAndStart()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = (isValid && cooldownSeconds == 0) || tunnelRunning,
|
enabled = (isValid && !cooldownActive) || tunnelRunning,
|
||||||
modifier = Modifier.weight(1f).height(52.dp),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(52.dp),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = buttonColor,
|
containerColor = buttonColor,
|
||||||
@@ -634,14 +737,14 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
|
|||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
tunnelRunning -> "Остановить"
|
tunnelRunning -> "Остановить"
|
||||||
cooldownSeconds > 0 -> "Подождите ($cooldownSeconds)"
|
cooldownActive -> "Подождите..."
|
||||||
else -> "Подключить"
|
else -> "Подключить"
|
||||||
},
|
},
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#00E5FF" />
|
||||||
|
<stroke android:width="1dp" android:color="#33F0FF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#2C2C2C" />
|
||||||
|
<stroke android:width="1dp" android:color="#444444" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
<solid android:color="#1A1A1A" />
|
||||||
|
<stroke android:width="1dp" android:color="#333333" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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="M2,4 L6.5,4 L10,14.5 L12,9 L14,14.5 L17.5,4 L22,4 L17,19 L13,19 L12,14.5 L11,19 L7,19 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_widget_card"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentLeft="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="WDTT"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Отключено"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/widget_toggle_btn"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_alignParentRight="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:background="@drawable/bg_widget_button_inactive"
|
||||||
|
android:src="@drawable/ic_tile_logo_w"
|
||||||
|
android:contentDescription="Toggle VPN"
|
||||||
|
android:padding="10dp"
|
||||||
|
android:scaleType="fitCenter" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="110dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:updatePeriodMillis="86400000"
|
||||||
|
android:initialLayout="@layout/vpn_widget"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen">
|
||||||
|
</appwidget-provider>
|
||||||
@@ -374,15 +374,13 @@ func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
|
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
|
||||||
w := bounds.Dx() / gridSize
|
|
||||||
h := bounds.Dy() / gridSize
|
|
||||||
col := index % gridSize
|
col := index % gridSize
|
||||||
row := index / gridSize
|
row := index / gridSize
|
||||||
return image.Rect(
|
return image.Rect(
|
||||||
bounds.Min.X+col*w,
|
bounds.Min.X+(col*bounds.Dx())/gridSize,
|
||||||
bounds.Min.Y+row*h,
|
bounds.Min.Y+(row*bounds.Dy())/gridSize,
|
||||||
bounds.Min.X+(col+1)*w,
|
bounds.Min.X+((col+1)*bounds.Dx())/gridSize,
|
||||||
bounds.Min.Y+(row+1)*h,
|
bounds.Min.Y+((row+1)*bounds.Dy())/gridSize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -9,6 +9,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var pktPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make([]byte, 2048)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPktBuf(size int) []byte {
|
||||||
|
b := pktPool.Get().([]byte)
|
||||||
|
if cap(b) < size {
|
||||||
|
b = make([]byte, size)
|
||||||
|
}
|
||||||
|
return b[:size]
|
||||||
|
}
|
||||||
|
|
||||||
|
func putPktBuf(b []byte) {
|
||||||
|
if cap(b) < 2048 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pktPool.Put(b[:cap(b)])
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
returnChBuf = 384
|
returnChBuf = 384
|
||||||
|
|
||||||
@@ -125,13 +146,14 @@ func (d *Dispatcher) readLoop() {
|
|||||||
d.clientAddr.Store(&addr)
|
d.clientAddr.Store(&addr)
|
||||||
atomic.AddInt64(&d.stats.TotalBytesUp, int64(n))
|
atomic.AddInt64(&d.stats.TotalBytesUp, int64(n))
|
||||||
|
|
||||||
pkt := make([]byte, n)
|
pkt := getPktBuf(n)
|
||||||
copy(pkt, buf[:n])
|
copy(pkt, buf[:n])
|
||||||
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
nw := len(d.workers)
|
nw := len(d.workers)
|
||||||
if nw == 0 {
|
if nw == 0 {
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
putPktBuf(pkt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +191,7 @@ func (d *Dispatcher) readLoop() {
|
|||||||
// Все workers перегружены — сдвигаем указатель, пакет дропается
|
// Все workers перегружены — сдвигаем указатель, пакет дропается
|
||||||
d.rrIndex = (idx + 1) % nw
|
d.rrIndex = (idx + 1) % nw
|
||||||
d.rrCount = 0
|
d.rrCount = 0
|
||||||
|
putPktBuf(pkt)
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -184,15 +207,18 @@ func (d *Dispatcher) writeLoop() {
|
|||||||
case pkt := <-d.ReturnCh:
|
case pkt := <-d.ReturnCh:
|
||||||
addrPtr := d.clientAddr.Load()
|
addrPtr := d.clientAddr.Load()
|
||||||
if addrPtr == nil {
|
if addrPtr == nil {
|
||||||
|
putPktBuf(pkt)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
addr := *addrPtr
|
addr := *addrPtr
|
||||||
if _, err := d.localConn.WriteTo(pkt, addr); err != nil {
|
if _, err := d.localConn.WriteTo(pkt, addr); err != nil {
|
||||||
if d.ctx.Err() != nil {
|
if d.ctx.Err() != nil {
|
||||||
|
putPktBuf(pkt)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt)))
|
atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt)))
|
||||||
|
putPktBuf(pkt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var groupAuthMutex sync.Mutex
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
workersPerGroup = 9
|
workersPerGroup = 9
|
||||||
@@ -32,7 +30,6 @@ func WorkerGroup(
|
|||||||
getConfig bool,
|
getConfig bool,
|
||||||
configCh chan<- string,
|
configCh chan<- string,
|
||||||
workerIDs []int,
|
workerIDs []int,
|
||||||
cycleDuration time.Duration,
|
|
||||||
pauseFlag *int32,
|
pauseFlag *int32,
|
||||||
deviceID, password string,
|
deviceID, password string,
|
||||||
stats *Stats,
|
stats *Stats,
|
||||||
@@ -296,5 +293,4 @@ type Credentials struct {
|
|||||||
CacheStreamID int
|
CacheStreamID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unused import suppressor
|
|
||||||
var _ = fmt.Sprintf
|
|
||||||
|
|||||||
+3
-5
@@ -13,7 +13,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView)
|
// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView)
|
||||||
@@ -284,18 +283,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gID := g + 1
|
gID := g + 1
|
||||||
cycle := time.Duration(defaultCycleSecs) * time.Second
|
|
||||||
var cc chan<- string
|
var cc chan<- string
|
||||||
if isFirst {
|
if isFirst {
|
||||||
cc = configCh
|
cc = configCh
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(groupID int, cycleDir time.Duration, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) {
|
go func(groupID int, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort,
|
WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort,
|
||||||
isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR)
|
isFirstGroup, configChan, workerIds, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR)
|
||||||
}(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady)
|
}(gID, isFirst, cc, ids, g, myWaitReady, mySignalReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
+34
-12
@@ -12,6 +12,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -21,6 +22,24 @@ import (
|
|||||||
"golang.org/x/crypto/chacha20poly1305"
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var aeadCache sync.Map
|
||||||
|
|
||||||
|
func getAEAD(key []byte) (cipher.AEAD, error) {
|
||||||
|
if len(key) != wrapKeyLen {
|
||||||
|
return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen)
|
||||||
|
}
|
||||||
|
keyStr := string(key)
|
||||||
|
if val, ok := aeadCache.Load(keyStr); ok {
|
||||||
|
return val.(cipher.AEAD), nil
|
||||||
|
}
|
||||||
|
aead, err := chacha20poly1305.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aeadCache.Store(keyStr, aead)
|
||||||
|
return aead, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Configuration ───
|
// ─── Configuration ───
|
||||||
|
|
||||||
// ObfsConfig holds per-session obfuscation parameters.
|
// ObfsConfig holds per-session obfuscation parameters.
|
||||||
@@ -43,20 +62,22 @@ func NewObfsConfig() *ObfsConfig {
|
|||||||
|
|
||||||
// ─── Per-direction state (sequence + timestamp counters) ───
|
// ─── Per-direction state (sequence + timestamp counters) ───
|
||||||
|
|
||||||
// ObfsState tracks monotonically increasing RTP sequence number and timestamp.
|
// ObfsState tracks monotonically increasing RTP sequence number and timestamp using a 48-bit packet counter.
|
||||||
type ObfsState struct {
|
type ObfsState struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seq uint16
|
initSeq uint16
|
||||||
ts uint32
|
initTs uint32
|
||||||
|
count uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewObfsState creates a state with random initial seq/ts.
|
// NewObfsState creates a state with random initial seq/ts and count=0.
|
||||||
func NewObfsState() *ObfsState {
|
func NewObfsState() *ObfsState {
|
||||||
var buf [6]byte
|
var buf [6]byte
|
||||||
rand.Read(buf[:])
|
rand.Read(buf[:])
|
||||||
return &ObfsState{
|
return &ObfsState{
|
||||||
seq: binary.BigEndian.Uint16(buf[0:2]),
|
initSeq: binary.BigEndian.Uint16(buf[0:2]),
|
||||||
ts: binary.BigEndian.Uint32(buf[2:6]),
|
initTs: binary.BigEndian.Uint32(buf[2:6]),
|
||||||
|
count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +110,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
seq := state.seq
|
c := state.count
|
||||||
ts := state.ts
|
state.count++
|
||||||
state.seq++
|
|
||||||
state.ts += 960 // 20ms frame @ 48kHz (OPUS standard)
|
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
seq := state.initSeq + uint16(c)
|
||||||
|
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
|
||||||
|
|
||||||
// Build nonce from RTP fields
|
// Build nonce from RTP fields
|
||||||
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
||||||
|
|
||||||
@@ -118,7 +140,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
|
|||||||
binary.BigEndian.PutUint32(out[4:8], ts)
|
binary.BigEndian.PutUint32(out[4:8], ts)
|
||||||
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
||||||
|
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := getAEAD(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +200,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
|
|||||||
|
|
||||||
// Build nonce and decrypt
|
// Build nonce and decrypt
|
||||||
nonce := obfsBuildNonce(ssrc, seq, ts)
|
nonce := obfsBuildNonce(ssrc, seq, ts)
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := getAEAD(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,7 @@ func LoadProfileFromDisk() (*SavedProfile, error) {
|
|||||||
return &sp, nil
|
return &sp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveProfileToDisk(sp SavedProfile) error {
|
|
||||||
data, err := json.MarshalIndent(sp, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(profileFile, data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// profileList contains paired User-Agent and Client Hints strings.
|
// profileList contains paired User-Agent and Client Hints strings.
|
||||||
var profileList = []Profile{
|
var profileList = []Profile{
|
||||||
|
|||||||
@@ -371,7 +371,9 @@ func RunSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout))
|
_ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout))
|
||||||
if _, writeErr := dtlsConn.Write(pkt); writeErr != nil {
|
_, writeErr := dtlsConn.Write(pkt)
|
||||||
|
putPktBuf(pkt)
|
||||||
|
if writeErr != nil {
|
||||||
log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr)
|
log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -403,11 +405,12 @@ func RunSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pkt := make([]byte, n)
|
pkt := getPktBuf(n)
|
||||||
copy(pkt, b[:n])
|
copy(pkt, b[:n])
|
||||||
select {
|
select {
|
||||||
case d.ReturnCh <- pkt:
|
case d.ReturnCh <- pkt:
|
||||||
case <-sessCtx.Done():
|
case <-sessCtx.Done():
|
||||||
|
putPktBuf(pkt)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import (
|
|||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
ActiveConnections int32
|
ActiveConnections int32
|
||||||
Reconnects int64
|
|
||||||
TotalBytesUp int64
|
TotalBytesUp int64
|
||||||
TotalBytesDown int64
|
TotalBytesDown int64
|
||||||
CredsErrors int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStats() *Stats {
|
func NewStats() *Stats {
|
||||||
|
|||||||
+6
-1
@@ -1,3 +1,8 @@
|
|||||||
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx8G -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
org.gradle.vfs.watch=true
|
||||||
|
kotlin.caching.enabled=true
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crypto/cipher"
|
||||||
|
|
||||||
"github.com/pion/dtls/v3"
|
"github.com/pion/dtls/v3"
|
||||||
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
||||||
"golang.org/x/crypto/chacha20poly1305"
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
@@ -45,8 +47,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
wgIfaceName = "wdtt0"
|
wgIfaceName = "wdtt0"
|
||||||
wgServerAddr = "10.66.66.1"
|
wgServerAddr = "10.66.66.1"
|
||||||
wgClientAddr = "10.66.66.2"
|
|
||||||
wgClientCIDR = wgClientAddr + "/32"
|
|
||||||
wgServerCIDR = wgServerAddr + "/24"
|
wgServerCIDR = wgServerAddr + "/24"
|
||||||
defaultInternalWGPort = 56001
|
defaultInternalWGPort = 56001
|
||||||
dns = "1.1.1.1"
|
dns = "1.1.1.1"
|
||||||
@@ -68,19 +68,12 @@ type PasswordEntry struct {
|
|||||||
ExpiresAt int64 `json:"expires_at"` // unix timestamp
|
ExpiresAt int64 `json:"expires_at"` // unix timestamp
|
||||||
DownBytes int64 `json:"down_bytes"` // скачано клиентом
|
DownBytes int64 `json:"down_bytes"` // скачано клиентом
|
||||||
UpBytes int64 `json:"up_bytes"` // отдано клиентом
|
UpBytes int64 `json:"up_bytes"` // отдано клиентом
|
||||||
|
VkHash string `json:"vk_hash,omitempty"`
|
||||||
|
Ports string `json:"ports,omitempty"` // "dtls,wg,tun"
|
||||||
|
IsDeactivated bool `json:"is_deactivated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Трафик главного пароля (владельца)
|
|
||||||
var (
|
|
||||||
mainPassDown int64
|
|
||||||
mainPassUp int64
|
|
||||||
)
|
|
||||||
|
|
||||||
// Онлайн-статус устройств
|
|
||||||
var (
|
|
||||||
activeDevices = make(map[string]int32) // deviceID -> кол-во активных коннектов
|
|
||||||
activeDevicesMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
MainPassword string `json:"main_password"`
|
MainPassword string `json:"main_password"`
|
||||||
@@ -120,6 +113,37 @@ func generatePassword() string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var publicIP string = ""
|
||||||
|
|
||||||
|
func getPublicIP() string {
|
||||||
|
if publicIP != "" {
|
||||||
|
return publicIP
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get("https://api.ipify.org")
|
||||||
|
if err != nil {
|
||||||
|
return "YOUR_SERVER_IP"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
ipBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "YOUR_SERVER_IP"
|
||||||
|
}
|
||||||
|
publicIP = string(bytes.TrimSpace(ipBytes))
|
||||||
|
return publicIP
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripVkUrl(url string) string {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if idx := strings.LastIndex(url, "/"); idx != -1 {
|
||||||
|
url = url[idx+1:]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(url, "?"); idx != -1 {
|
||||||
|
url = url[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(url)
|
||||||
|
}
|
||||||
|
|
||||||
type wrapKeyEntry struct {
|
type wrapKeyEntry struct {
|
||||||
id string
|
id string
|
||||||
key []byte
|
key []byte
|
||||||
@@ -340,7 +364,7 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
|
|
||||||
// Устанавливаем команды для синей кнопки Menu
|
// Устанавливаем команды для синей кнопки Menu
|
||||||
go func() {
|
go func() {
|
||||||
cmds := `{"commands":[{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}`
|
cmds := `{"commands":[{"command":"start","description":"Главное меню"},{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}`
|
||||||
resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds))
|
resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -350,8 +374,14 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
offset := 0
|
offset := 0
|
||||||
client := &http.Client{Timeout: 65 * time.Second}
|
client := &http.Client{Timeout: 65 * time.Second}
|
||||||
|
|
||||||
// Состояние ожидания ввода дней
|
// Состояние ожидания ввода
|
||||||
var waitingForDays bool
|
var waitingForDays bool
|
||||||
|
var waitingForPorts bool
|
||||||
|
var waitingForHash bool
|
||||||
|
var targetPassword string
|
||||||
|
|
||||||
|
var tempDays int
|
||||||
|
var tempPorts string // "dtls,wg,tun"
|
||||||
|
|
||||||
for {
|
for {
|
||||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset)
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset)
|
||||||
@@ -410,6 +440,22 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass)
|
txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass)
|
||||||
|
if entry.VkHash != "" {
|
||||||
|
ports := entry.Ports
|
||||||
|
if ports == "" {
|
||||||
|
ports = "56000,56001,9000"
|
||||||
|
}
|
||||||
|
pts := strings.Split(ports, ",")
|
||||||
|
srvIP := getPublicIP()
|
||||||
|
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], pass, entry.VkHash)
|
||||||
|
txt += fmt.Sprintf("🔗 *Быстрая ссылка:* `%s`\n", link)
|
||||||
|
}
|
||||||
|
if entry.IsDeactivated {
|
||||||
|
txt += "🔴 Статус: *ДЕАКТИВИРОВАН*\n"
|
||||||
|
} else {
|
||||||
|
txt += "🟢 Статус: *АКТИВЕН*\n"
|
||||||
|
}
|
||||||
|
|
||||||
if entry.ExpiresAt > 0 {
|
if entry.ExpiresAt > 0 {
|
||||||
expireTime := time.Unix(entry.ExpiresAt, 0)
|
expireTime := time.Unix(entry.ExpiresAt, 0)
|
||||||
remaining := time.Until(expireTime)
|
remaining := time.Until(expireTime)
|
||||||
@@ -421,6 +467,8 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
} else {
|
} else {
|
||||||
txt += "⏰ Бессрочный ♾\n"
|
txt += "⏰ Бессрочный ♾\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
txt += fmt.Sprintf("\n📊 *Трафик:*\n• Скачано: %.2f MB\n• Отдано: %.2f MB\n", float64(entry.DownBytes)/(1024*1024), float64(entry.UpBytes)/(1024*1024))
|
||||||
txt += "\n📱 *Привязанное устройство:*\n"
|
txt += "\n📱 *Привязанное устройство:*\n"
|
||||||
var kb []map[string]interface{}
|
var kb []map[string]interface{}
|
||||||
if entry.DeviceID == "" {
|
if entry.DeviceID == "" {
|
||||||
@@ -438,6 +486,17 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
dbMutex.Unlock()
|
dbMutex.Unlock()
|
||||||
|
if entry.IsDeactivated {
|
||||||
|
kb = append(kb, map[string]interface{}{
|
||||||
|
"text": "✅ Активировать",
|
||||||
|
"callback_data": "react_" + pass,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
kb = append(kb, map[string]interface{}{
|
||||||
|
"text": "⏸ Деактивировать",
|
||||||
|
"callback_data": "deact_" + pass,
|
||||||
|
})
|
||||||
|
}
|
||||||
kb = append(kb, map[string]interface{}{
|
kb = append(kb, map[string]interface{}{
|
||||||
"text": "❌ Удалить пароль",
|
"text": "❌ Удалить пароль",
|
||||||
"callback_data": "delpass_" + pass,
|
"callback_data": "delpass_" + pass,
|
||||||
@@ -452,6 +511,44 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
}
|
}
|
||||||
sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard})
|
sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard})
|
||||||
|
|
||||||
|
} else if strings.HasPrefix(data, "deact_") {
|
||||||
|
pass := strings.TrimPrefix(data, "deact_")
|
||||||
|
dbMutex.Lock()
|
||||||
|
entry, exists := db.Passwords[pass]
|
||||||
|
if exists && entry != nil {
|
||||||
|
entry.IsDeactivated = true
|
||||||
|
// Отключаем активное устройство от WG если нужно
|
||||||
|
if entry.DeviceID != "" {
|
||||||
|
if dev, devExists := db.Devices[entry.DeviceID]; devExists {
|
||||||
|
pubHex, _ := b64ToHex(dev.PubKey)
|
||||||
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveDB()
|
||||||
|
}
|
||||||
|
dbMutex.Unlock()
|
||||||
|
sendTelegram(token, adminID, fmt.Sprintf("⏸ Пароль `%s` деактивирован", pass), nil)
|
||||||
|
|
||||||
|
} else if strings.HasPrefix(data, "react_") {
|
||||||
|
pass := strings.TrimPrefix(data, "react_")
|
||||||
|
dbMutex.Lock()
|
||||||
|
entry, exists := db.Passwords[pass]
|
||||||
|
if exists && entry != nil {
|
||||||
|
entry.IsDeactivated = false
|
||||||
|
saveDB()
|
||||||
|
}
|
||||||
|
dbMutex.Unlock()
|
||||||
|
sendTelegram(token, adminID, fmt.Sprintf("✅ Пароль `%s` активирован", pass), nil)
|
||||||
|
|
||||||
|
} else if data == "mainlink" {
|
||||||
|
targetPassword = "main"
|
||||||
|
var keyboard [][]map[string]interface{}
|
||||||
|
keyboard = append(keyboard, []map[string]interface{}{
|
||||||
|
{"text": "Да", "callback_data": "ports_def"},
|
||||||
|
{"text": "Нет", "callback_data": "ports_custom"},
|
||||||
|
})
|
||||||
|
sendTelegram(token, adminID, "⚙️ Использовать стандартные порты для главного пароля (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard})
|
||||||
|
|
||||||
} else if strings.HasPrefix(data, "unbind_") {
|
} else if strings.HasPrefix(data, "unbind_") {
|
||||||
pass := strings.TrimPrefix(data, "unbind_")
|
pass := strings.TrimPrefix(data, "unbind_")
|
||||||
dbMutex.Lock()
|
dbMutex.Lock()
|
||||||
@@ -509,6 +606,13 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
|
|
||||||
} else if data == "backlist" {
|
} else if data == "backlist" {
|
||||||
sendPasswordList(token, adminID, wgDev)
|
sendPasswordList(token, adminID, wgDev)
|
||||||
|
} else if data == "ports_def" {
|
||||||
|
tempPorts = "56000,56001,9000"
|
||||||
|
waitingForHash = true
|
||||||
|
sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil)
|
||||||
|
} else if data == "ports_custom" {
|
||||||
|
waitingForPorts = true
|
||||||
|
sendTelegram(token, adminID, "⚙️ Укажите через запятую 3 порта (DTLS,WG,TUN):\nНапример: 56000,56001,9000", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +632,68 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil)
|
sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
expiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix()
|
tempDays = days
|
||||||
|
|
||||||
|
var keyboard [][]map[string]interface{}
|
||||||
|
keyboard = append(keyboard, []map[string]interface{}{
|
||||||
|
{"text": "Да", "callback_data": "ports_def"},
|
||||||
|
{"text": "Нет", "callback_data": "ports_custom"},
|
||||||
|
})
|
||||||
|
sendTelegram(token, adminID, "⚙️ Использовать стандартные порты (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if waitingForPorts {
|
||||||
|
parts := strings.Split(cmd, ",")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
sendTelegram(token, adminID, "❌ Неверный формат. Укажите 3 порта через запятую (например: 56000,56001,9000):", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p1 := strings.TrimSpace(parts[0])
|
||||||
|
p2 := strings.TrimSpace(parts[1])
|
||||||
|
p3 := strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
if _, err := strconv.Atoi(p1); err != nil {
|
||||||
|
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(p2); err != nil {
|
||||||
|
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(p3); err != nil {
|
||||||
|
sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
waitingForPorts = false
|
||||||
|
tempPorts = fmt.Sprintf("%s,%s,%s", p1, p2, p3)
|
||||||
|
waitingForHash = true
|
||||||
|
sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if waitingForHash {
|
||||||
|
hash := strings.ReplaceAll(cmd, " ", "")
|
||||||
|
if strings.Contains(hash, "http") || strings.Contains(hash, "/") {
|
||||||
|
sendTelegram(token, adminID, "❌ Пожалуйста, отправьте только хеш (или несколько хешей через запятую). Ссылки не поддерживаются.", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hash == "" {
|
||||||
|
sendTelegram(token, adminID, "❌ Хеш не должен быть пустым.", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
waitingForHash = false
|
||||||
|
|
||||||
|
if targetPassword == "main" {
|
||||||
|
targetPassword = ""
|
||||||
|
srvIP := getPublicIP()
|
||||||
|
pts := strings.Split(tempPorts, ",")
|
||||||
|
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], db.MainPassword, hash)
|
||||||
|
sendTelegram(token, adminID, fmt.Sprintf("🔗 *Ссылка для главного пароля:*\n`%s`", link), nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
dbMutex.Lock()
|
dbMutex.Lock()
|
||||||
if cleanupExpiredPasswordsLocked(wgDev) > 0 {
|
if cleanupExpiredPasswordsLocked(wgDev) > 0 {
|
||||||
saveDB()
|
saveDB()
|
||||||
@@ -556,11 +721,21 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|||||||
sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil)
|
sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
db.Passwords[newPass] = &PasswordEntry{ExpiresAt: expiresAt}
|
expiresAt := time.Now().Add(time.Duration(tempDays) * 24 * time.Hour).Unix()
|
||||||
|
db.Passwords[newPass] = &PasswordEntry{
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
VkHash: hash,
|
||||||
|
Ports: tempPorts,
|
||||||
|
}
|
||||||
saveDB()
|
saveDB()
|
||||||
dbMutex.Unlock()
|
dbMutex.Unlock()
|
||||||
|
|
||||||
expDate := time.Unix(expiresAt, 0).Format("02.01.2006")
|
expDate := time.Unix(expiresAt, 0).Format("02.01.2006")
|
||||||
sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения", newPass, days, expDate), nil)
|
srvIP := getPublicIP()
|
||||||
|
pts := strings.Split(tempPorts, ",")
|
||||||
|
link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], newPass, hash)
|
||||||
|
|
||||||
|
sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения\n\n🔗 *Быстрая ссылка:* `%s`", newPass, tempDays, expDate, link), nil)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +852,10 @@ func sendPasswordList(token string, adminID int64, wgDev *device.Device) {
|
|||||||
txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword)
|
txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword)
|
||||||
|
|
||||||
var inlineKb []map[string]interface{}
|
var inlineKb []map[string]interface{}
|
||||||
|
inlineKb = append(inlineKb, map[string]interface{}{
|
||||||
|
"text": "🔗 Ссылка на главный пароль",
|
||||||
|
"callback_data": "mainlink",
|
||||||
|
})
|
||||||
|
|
||||||
if len(db.Passwords) == 0 {
|
if len(db.Passwords) == 0 {
|
||||||
txt += "_Нет сгенерированных паролей._\n"
|
txt += "_Нет сгенерированных паролей._\n"
|
||||||
@@ -1224,7 +1403,6 @@ func main() {
|
|||||||
func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) {
|
func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) {
|
||||||
atomic.AddInt64(&totalConns, 1)
|
atomic.AddInt64(&totalConns, 1)
|
||||||
|
|
||||||
var connDeviceID string
|
|
||||||
var connPassword string
|
var connPassword string
|
||||||
var connIsMainPass bool
|
var connIsMainPass bool
|
||||||
|
|
||||||
@@ -1276,14 +1454,16 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
|
|||||||
entry, isGenPass := db.Passwords[password]
|
entry, isGenPass := db.Passwords[password]
|
||||||
valid := isMainPass || (isGenPass && !isPasswordExpired(entry))
|
valid := isMainPass || (isGenPass && !isPasswordExpired(entry))
|
||||||
|
|
||||||
// Для сгенерированных паролей — проверяем привязку к устройству
|
if valid && isGenPass && entry.IsDeactivated {
|
||||||
if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID {
|
clientConn.Write([]byte("DENIED:deactivated"))
|
||||||
|
log.Printf("[WG] Отказ: пароль %s деактивирован, запрос от %s", maskPassword(password), deviceID)
|
||||||
|
dbMutex.Unlock()
|
||||||
|
} else if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID {
|
||||||
// Пароль уже привязан к другому устройству
|
// Пароль уже привязан к другому устройству
|
||||||
clientConn.Write([]byte("DENIED:device_mismatch"))
|
clientConn.Write([]byte("DENIED:device_mismatch"))
|
||||||
log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID)
|
log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID)
|
||||||
dbMutex.Unlock()
|
dbMutex.Unlock()
|
||||||
} else if valid {
|
} else if valid {
|
||||||
connDeviceID = deviceID
|
|
||||||
connPassword = password
|
connPassword = password
|
||||||
connIsMainPass = isMainPass
|
connIsMainPass = isMainPass
|
||||||
|
|
||||||
@@ -1364,20 +1544,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
|
|||||||
}
|
}
|
||||||
atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket)))
|
atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket)))
|
||||||
|
|
||||||
// Трекинг онлайн-статуса
|
|
||||||
if connDeviceID != "" {
|
|
||||||
activeDevicesMu.Lock()
|
|
||||||
activeDevices[connDeviceID]++
|
|
||||||
activeDevicesMu.Unlock()
|
|
||||||
defer func() {
|
|
||||||
activeDevicesMu.Lock()
|
|
||||||
activeDevices[connDeviceID]--
|
|
||||||
if activeDevices[connDeviceID] <= 0 {
|
|
||||||
delete(activeDevices, connDeviceID)
|
|
||||||
}
|
|
||||||
activeDevicesMu.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
pctx, pcancel := context.WithCancel(ctx)
|
pctx, pcancel := context.WithCancel(ctx)
|
||||||
defer pcancel()
|
defer pcancel()
|
||||||
@@ -1413,9 +1580,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
|
|||||||
}
|
}
|
||||||
atomic.AddInt64(&totalBytesFromClient, int64(nn))
|
atomic.AddInt64(&totalBytesFromClient, int64(nn))
|
||||||
// Per-password upload tracking
|
// Per-password upload tracking
|
||||||
if connIsMainPass {
|
if connPassword != "" && !connIsMainPass {
|
||||||
atomic.AddInt64(&mainPassUp, int64(nn))
|
|
||||||
} else if connPassword != "" {
|
|
||||||
dbMutex.Lock()
|
dbMutex.Lock()
|
||||||
e, ok := db.Passwords[connPassword]
|
e, ok := db.Passwords[connPassword]
|
||||||
if !ok || e == nil || isPasswordExpired(e) {
|
if !ok || e == nil || isPasswordExpired(e) {
|
||||||
@@ -1456,9 +1621,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
|
|||||||
}
|
}
|
||||||
atomic.AddInt64(&totalBytesToClient, int64(nn))
|
atomic.AddInt64(&totalBytesToClient, int64(nn))
|
||||||
// Per-password download tracking
|
// Per-password download tracking
|
||||||
if connIsMainPass {
|
if connPassword != "" && !connIsMainPass {
|
||||||
atomic.AddInt64(&mainPassDown, int64(nn))
|
|
||||||
} else if connPassword != "" {
|
|
||||||
dbMutex.Lock()
|
dbMutex.Lock()
|
||||||
e, ok := db.Passwords[connPassword]
|
e, ok := db.Passwords[connPassword]
|
||||||
if !ok || e == nil || isPasswordExpired(e) {
|
if !ok || e == nil || isPasswordExpired(e) {
|
||||||
@@ -1482,6 +1645,24 @@ const (
|
|||||||
wrapKeyLen = 32
|
wrapKeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var aeadCache sync.Map
|
||||||
|
|
||||||
|
func getAEAD(key []byte) (cipher.AEAD, error) {
|
||||||
|
if len(key) != wrapKeyLen {
|
||||||
|
return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen)
|
||||||
|
}
|
||||||
|
keyStr := string(key)
|
||||||
|
if val, ok := aeadCache.Load(keyStr); ok {
|
||||||
|
return val.(cipher.AEAD), nil
|
||||||
|
}
|
||||||
|
aead, err := chacha20poly1305.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aeadCache.Store(keyStr, aead)
|
||||||
|
return aead, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== RTP Обфускация ====================
|
// ==================== RTP Обфускация ====================
|
||||||
|
|
||||||
type ObfsConfig struct {
|
type ObfsConfig struct {
|
||||||
@@ -1492,8 +1673,9 @@ type ObfsConfig struct {
|
|||||||
|
|
||||||
type ObfsState struct {
|
type ObfsState struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seq uint16
|
initSeq uint16
|
||||||
ts uint32
|
initTs uint32
|
||||||
|
count uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewObfsConfig() *ObfsConfig {
|
func NewObfsConfig() *ObfsConfig {
|
||||||
@@ -1510,8 +1692,9 @@ func NewObfsState() *ObfsState {
|
|||||||
var buf [6]byte
|
var buf [6]byte
|
||||||
rand.Read(buf[:])
|
rand.Read(buf[:])
|
||||||
return &ObfsState{
|
return &ObfsState{
|
||||||
seq: binary.BigEndian.Uint16(buf[0:2]),
|
initSeq: binary.BigEndian.Uint16(buf[0:2]),
|
||||||
ts: binary.BigEndian.Uint32(buf[2:6]),
|
initTs: binary.BigEndian.Uint32(buf[2:6]),
|
||||||
|
count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1531,12 +1714,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
|
|||||||
return nil, errors.New("obfs: empty payload")
|
return nil, errors.New("obfs: empty payload")
|
||||||
}
|
}
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
seq := state.seq
|
c := state.count
|
||||||
ts := state.ts
|
state.count++
|
||||||
state.seq++
|
|
||||||
state.ts += 960
|
|
||||||
state.mu.Unlock()
|
state.mu.Unlock()
|
||||||
|
|
||||||
|
seq := state.initSeq + uint16(c)
|
||||||
|
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
|
||||||
|
|
||||||
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
||||||
padRand := 0
|
padRand := 0
|
||||||
if cfg.PaddingMax > 0 {
|
if cfg.PaddingMax > 0 {
|
||||||
@@ -1554,7 +1738,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b
|
|||||||
binary.BigEndian.PutUint32(out[4:8], ts)
|
binary.BigEndian.PutUint32(out[4:8], ts)
|
||||||
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
||||||
|
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := getAEAD(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1597,7 +1781,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
|
|||||||
return 0, errors.New("obfs: dst buffer too small")
|
return 0, errors.New("obfs: dst buffer too small")
|
||||||
}
|
}
|
||||||
nonce := obfsBuildNonce(ssrc, seq, ts)
|
nonce := obfsBuildNonce(ssrc, seq, ts)
|
||||||
aead, err := chacha20poly1305.New(key)
|
aead, err := getAEAD(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user