mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
Compare commits
3 Commits
6a253bf9ad
...
4f3a14d828
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3a14d828 | ||
|
|
a43d61bc38 | ||
|
|
d1ca465f83 |
66
README.md
66
README.md
@@ -1,44 +1,29 @@
|
||||
# TG WS Proxy (Android) (1.0.7 - будет проведен второй редизайн и оптимизация производительности интерфейса)
|
||||
# TG WS Proxy Android
|
||||
Это мобильный форк популярного [TG WS прокси](https://github.com/Flowseal/tg-ws-proxy) от [Flowseal](https://github.com/Flowseal), переработанный для комфортного использования на современных смартфонах. - **Локальный MTProto-прокси** для Telegram на Android. Помогает частично обходить блокировки и ускоряет работу мессенджера, перенаправляя трафик через защищённые CloudFlare WebSocket-соединения или напрямую к датацентрам.
|
||||
|
||||
**Локальный MTProto-прокси** для Telegram Android, который **ускоряет работу Telegram**, перенаправляя трафик через защищённые CloudFlare WebSocket-соединения или напрямую.
|
||||
---
|
||||
|
||||
<img width="969" height="646" alt="MyCollages" src="https://github.com/user-attachments/assets/cd074a98-8e73-48a7-a5b9-089106a6cd5b" />
|
||||
(не самый ровный интерфейс но я сделал апдейт на скорую руку считай)
|
||||
<img width="1000" height="665" alt="tg-ws-proxy" src="https://github.com/user-attachments/assets/34a5a0db-5328-4280-a4b3-3927b237dfee" />
|
||||
|
||||
Это мобильный форк популярного WS прокси, кардинально переработанный для удобного использования на смартфонах.
|
||||
---
|
||||
|
||||
> [!CAUTION]
|
||||
> ### 🔴 ВАЖНО: Теперь работает на мобильных сетях.
|
||||
> ### Приложение работает "из коробки". Перед использованием нажмите кнопку "Пожалуйста, ознакомьтесь" внутри приложения.
|
||||
## Возможности Android-версии
|
||||
|
||||
## 🌟 Что реализовано в Android-версии
|
||||
- **Современный UI:** Управление на базе Material 3 (Jetpack Compose). Настройка в пару тапов.
|
||||
- **Интеграция с Telegram:** Кнопка «Применить» автоматически прописывает прокси в любом клиенте (AyuGram, Plus, NekoGram и др.) через `tg://proxy`.
|
||||
- **Фоновый режим:** Использует `Foreground Service`, чтобы система не закрывала прокси при очистке памяти.
|
||||
- **Лог-вьюер:** Просмотр событий в реальном времени для быстрой диагностики.
|
||||
- **Темы и палитры:** Поддержка Dynamic Colors (Android 12+) и встроенные цветовые схемы (Индиго, Лес, Эспрессо) для более старых версий.
|
||||
|
||||
Функции управления вынесены в красивый и удобный **Material 3** интерфейс (Jetpack Compose).
|
||||
|
||||
- **Полноценный UI:** Настройка порта, пула датацентров и режима CloudFlare делается в 2 клика.
|
||||
- **Интеграция с Telegram:** Кнопка «Применить в телеграмм» автоматически настроит прокси через систему глубоких ссылок (`tg://proxy`) для любого установленного клиента (AyuGram, Plus Messenger, NekoGram и др.).
|
||||
- **Стабильная работа в фоне:** Приложение использует «неубиваемый» `Foreground Service` и самоконтроль Wakelock'ов, чтобы Android не "душил" прокси в спящем режиме.
|
||||
- **Встроенный просмотрщик логов:** В реальном времени сгруппировано отображаются логи работы для удобной диагностики без падения FPS.
|
||||
- **Динамические цвета и темы:** Поддержка светлой и темной тем, а также Material You (в Android 12+).
|
||||
|
||||
## 🆕 Что нового (v1.0.6)
|
||||
|
||||
**ОБНОВЛЕНИЕ ВЫПУЩЕНО ПО МОТИВАМ ВЕРСИИ 1.6.1 от FlowSeal**
|
||||
|
||||
* **Ядро проксирования было полностью переписано под протокол MTProto — техническая стабильность и общая скорость подключения теперь выше**
|
||||
* **Интегрировано продвинутое проксирование через CloudFlare — внедрён автоматический режим получения DC от Telegram, лучше подходит для использования с проксированием через CloudFlare**
|
||||
* **Сохранён классический режим ручной настройки датацентров — по умолчанию отказоустойчивый IP зафиксирован на лондонском узле `149.154.167.220` (DC4)**
|
||||
* **Реализована полная кросс-архитектурная совместимость — теперь ядро и приложение нативно поддерживает как актуальные устройства `arm64-v8a`, так и более старые `armeabi-v7a`**
|
||||
* **Проведён масштабный редизайн приложения — внедрена компоновка, переработаны модальные окна, добавлена удобная полуавтоматическая система проверки обновлений**
|
||||
* **Багфикс — исправлена проблема со слетающей тёмной/светлой темой UI**
|
||||
|
||||
💡 **СОВЕТ ДЛЯ ПОЛЬЗОВАТЕЛЕЙ:**
|
||||
Приложение уже оптимально настроено и готово к работе "из коробки". **Крайне рекомендуем нажать на кнопку «Пожалуйста, ознакомьтесь» перед стартом.**
|
||||
Если вы точно знаете, что делаете — вы можете менять порты, отключать проксирование через CloudFlare и задавать ручные адреса. Но если не уверены в назначении тумблера — лучше оставьте его по умолчанию и следуйте инструкциям в "Пожалуйста ознакомьтесь" ниже "Применить в Telegram"!
|
||||
|
||||
**Подключение через CloudFlare может занимать около 1-10 секунд, см лог событий. В случае проблем подключения, попробуйте отключить CloudFlare и вернуться на ручные адреса DC, в противном случае - пожалуйста поднимите вопрос.**
|
||||
## Что нового (v1.0.7)
|
||||
|
||||
* **Оптимизация UI:** Повышена плавность интерфейса и скорость отклика.
|
||||
* **Совместимость:** Поддержка Android от **7.0** до **16-й** версии (SDK 36). Нативная работа на `arm64-v8a` и `armeabi-v7a`.
|
||||
* **Умные DC:** Автоматическая DC-адресация при включенном CloudFlare. В ручном режиме по умолчанию используется лондонский узел (149.154.167.220).
|
||||
* **Прямая ссылка:** Добавлено копирование ссылки `tg://proxy?` для ручного добавления в моды.
|
||||
* **FIX:** Исправлена критическая ошибка в секретном ключе (переход с `ee` на корректный `dd`), устраняющая проблемы с рукопожатием.
|
||||
|
||||
---
|
||||
|
||||
## Как это работает
|
||||
|
||||
@@ -51,15 +36,24 @@ Telegram Android → Локальный MTProto (по умолчанию 127.0.0
|
||||
3. Извлекает DC ID из оригинального пакета и устанавливает защищенное WebSocket (TLS) соединение с нужным датацентром, при необходимости проксируя через сеть CloudFlare.
|
||||
4. Эффективно мультиплексирует трафик.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
## Быстрый старт
|
||||
|
||||
1. Перейдите на **[страницу релизов]** и скачайте актуальный `APK`-файл.
|
||||
1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/tg-ws-proxy-android/releases)**.
|
||||
2. Установите приложение на ваш Android-смартфон.
|
||||
3. Откройте **TG WS Proxy**.
|
||||
4. Ознакомьтесь со справкой.
|
||||
5. Нажмите **«Запустить прокси»** — появится уведомление о работе в фоновом режиме.
|
||||
6. Нажмите **«Применить в телеграмм»** — откроется клиент Telegram, останется только нажать «Подключить».
|
||||
|
||||
---
|
||||
|
||||
> [!CAUTION]
|
||||
> ### Теперь работает на мобильных сетях. Приложение настроено из коробки.
|
||||
> ### Режим без CloudFlare лучше использовать на Wi-Fi. Если скорости на DC4 не хватает, попробуйте прописать тот же IP в поле DC2. Можно так же использовать CloudFlare на Wi-Fi.
|
||||
> ### На мобильных сетях блокировки значительно *агрессивнее*, поэтому стабильность работы может зависеть от оператора.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Этот форк распространяется под лицензией **GPLv3**. (Оригинальный код `tg-ws-proxy` доступен под MIT). Файл лицензии приложен к исходному коду. Автор оригинальной программы - [Flowseal](https://github.com/Flowseal)
|
||||
Этот форк распространяется под лицензией **GPLv3**. Оригинальный код `tg-ws-proxy` от [Flowseal](https://github.com/Flowseal) доступен под MIT.
|
||||
|
||||
@@ -11,10 +11,10 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.amurcanov.tgwsproxy"
|
||||
minSdk = 26
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.51"
|
||||
versionName = "1.0.7"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -65,9 +65,13 @@ class MainActivity : ComponentActivity() {
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
val themeMode by settingsStore.themeMode
|
||||
.collectAsStateWithLifecycle(initialValue = "system")
|
||||
val isDynamicColor by settingsStore.isDynamicColor
|
||||
.collectAsStateWithLifecycle(initialValue = true)
|
||||
val themePalette by settingsStore.themePalette
|
||||
.collectAsStateWithLifecycle(initialValue = "indigo")
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
TgWsProxyTheme(themeMode = themeMode) {
|
||||
TgWsProxyTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density(
|
||||
density = androidx.compose.ui.platform.LocalDensity.current.density,
|
||||
@@ -85,6 +89,14 @@ class MainActivity : ComponentActivity() {
|
||||
currentTheme = themeMode,
|
||||
onThemeChange = { mode ->
|
||||
scope.launch { settingsStore.saveThemeMode(mode) }
|
||||
},
|
||||
isDynamicColor = isDynamicColor,
|
||||
onDynamicColorChange = { dc ->
|
||||
scope.launch { settingsStore.saveDynamicColor(dc) }
|
||||
},
|
||||
currentPalette = themePalette,
|
||||
onPaletteChange = { pal ->
|
||||
scope.launch { settingsStore.saveThemePalette(pal) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -127,8 +139,9 @@ fun MainContent(settingsStore: SettingsStore) {
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
DisposableEffect(Unit) {
|
||||
LogManager.startListening()
|
||||
onDispose { LogManager.stopListening() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -206,19 +219,21 @@ object LogManager {
|
||||
if (job?.isActive == true) return
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
// Start logcat reader coroutine
|
||||
val readerJob = launch {
|
||||
val readerJob = launch(Dispatchers.IO) {
|
||||
try {
|
||||
val pid = android.os.Process.myPid()
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf("logcat", "-v", "tag", "--pid", pid.toString())
|
||||
)
|
||||
val process = ProcessBuilder("logcat", "-v", "tag", "--pid", pid.toString())
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
logcatProcess = process
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream), 8192)
|
||||
|
||||
while (isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
val entry = parseLine(line) ?: continue
|
||||
logChannel.trySend(entry) // non-blocking send
|
||||
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
while (isActive) {
|
||||
val line = try { reader.readLine() } catch (e: Exception) { null } ?: break
|
||||
val entry = parseLine(line) ?: continue
|
||||
logChannel.trySend(entry)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
@@ -259,32 +274,26 @@ object LogManager {
|
||||
* Merges consecutive duplicates and caps at 50 entries.
|
||||
*/
|
||||
private fun applyBatch(current: List<LogEntry>, batch: List<LogEntry>): List<LogEntry> {
|
||||
// Use a pre-sized ArrayList to avoid re-allocation
|
||||
val result = ArrayList<LogEntry>(minOf(current.size + batch.size, 50))
|
||||
result.addAll(current)
|
||||
|
||||
val result = ArrayDeque(current)
|
||||
for (entry in batch) {
|
||||
var merged = false
|
||||
val searchDepth = minOf(result.size, 10)
|
||||
for (i in result.lastIndex downTo result.size - searchDepth) {
|
||||
for (i in result.indices.reversed().take(searchDepth)) {
|
||||
if (result[i].message == entry.message) {
|
||||
val existing = result.removeAt(i)
|
||||
result.add(existing.copy(count = existing.count + 1))
|
||||
result.addLast(existing.copy(count = existing.count + 1))
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
result.add(entry)
|
||||
result.addLast(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Trim to 50 entries from the end
|
||||
return if (result.size > 50) {
|
||||
result.subList(result.size - 50, result.size).toList()
|
||||
} else {
|
||||
result
|
||||
while (result.size > 50) {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result.toList()
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ProxyLibrary : Library {
|
||||
fun SetPoolSize(size: Int)
|
||||
fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String)
|
||||
fun SetSecret(secret: String)
|
||||
fun SetFakeTls(enabled: Int, domain: String)
|
||||
fun GetSecretWithPrefix(): Pointer?
|
||||
fun GetStats(): Pointer?
|
||||
fun FreeString(p: Pointer)
|
||||
}
|
||||
@@ -38,6 +40,16 @@ object NativeProxy {
|
||||
fun setSecret(secret: String) {
|
||||
ProxyLibrary.INSTANCE.SetSecret(secret)
|
||||
}
|
||||
fun setFakeTls(enabled: Boolean, domain: String = "") {
|
||||
ProxyLibrary.INSTANCE.SetFakeTls(if (enabled) 1 else 0, domain)
|
||||
}
|
||||
/** Returns the full secret with correct prefix (dd or ee+domain_hex) */
|
||||
fun getSecretWithPrefix(): String? {
|
||||
val ptr = ProxyLibrary.INSTANCE.GetSecretWithPrefix() ?: return null
|
||||
val res = ptr.getString(0)
|
||||
ProxyLibrary.INSTANCE.FreeString(ptr)
|
||||
return res
|
||||
}
|
||||
fun getStats(): String? {
|
||||
val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null
|
||||
val res = ptr.getString(0)
|
||||
|
||||
@@ -20,6 +20,7 @@ class ProxyService : Service() {
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var statsJob: Job? = null
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.amurcanov.tgwsproxy.START"
|
||||
@@ -87,7 +88,7 @@ class ProxyService : Service() {
|
||||
|
||||
_isRunning.value = true
|
||||
|
||||
statsJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
statsJob = serviceScope.launch {
|
||||
while (isActive) {
|
||||
delay(2000)
|
||||
if (_isRunning.value) {
|
||||
@@ -197,6 +198,7 @@ class ProxyService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
if (_isRunning.value) {
|
||||
stopProxy()
|
||||
}
|
||||
|
||||
@@ -17,22 +17,30 @@ class SettingsStore(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val IS_DYNAMIC_COLOR = booleanPreferencesKey("is_dynamic_color")
|
||||
val THEME_PALETTE = stringPreferencesKey("theme_palette")
|
||||
val IS_DC_AUTO = booleanPreferencesKey("is_dc_auto")
|
||||
val DC2 = stringPreferencesKey("dc2")
|
||||
val DC4 = stringPreferencesKey("dc4")
|
||||
val PORT = stringPreferencesKey("port")
|
||||
val POOL_SIZE = intPreferencesKey("pool_size")
|
||||
val CFPROXY_ENABLED = booleanPreferencesKey("cfproxy_enabled")
|
||||
val CUSTOM_CF_DOMAIN_ENABLED = booleanPreferencesKey("custom_cf_domain_enabled")
|
||||
val CUSTOM_CF_DOMAIN = stringPreferencesKey("custom_cf_domain")
|
||||
val SECRET_KEY = stringPreferencesKey("secret_key")
|
||||
}
|
||||
|
||||
val themeMode: Flow<String> = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" }
|
||||
val isDynamicColor: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DYNAMIC_COLOR] ?: true }
|
||||
val themePalette: Flow<String> = context.dataStore.data.map { it[Keys.THEME_PALETTE] ?: "indigo" }
|
||||
val isDcAuto: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true }
|
||||
val dc2: Flow<String> = context.dataStore.data.map { it[Keys.DC2] ?: "" }
|
||||
val dc4: Flow<String> = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" }
|
||||
val port: Flow<String> = context.dataStore.data.map { it[Keys.PORT] ?: "1443" }
|
||||
val poolSize: Flow<Int> = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 }
|
||||
val cfproxyEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true }
|
||||
val customCfDomainEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN_ENABLED] ?: false }
|
||||
val customCfDomain: Flow<String> = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN] ?: "" }
|
||||
val secretKey: Flow<String> = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" }
|
||||
|
||||
suspend fun saveSecretKey(key: String) {
|
||||
@@ -43,8 +51,16 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.THEME_MODE] = mode }
|
||||
}
|
||||
|
||||
suspend fun saveDynamicColor(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.IS_DYNAMIC_COLOR] = enabled }
|
||||
}
|
||||
|
||||
suspend fun saveThemePalette(palette: String) {
|
||||
context.dataStore.edit { it[Keys.THEME_PALETTE] = palette }
|
||||
}
|
||||
|
||||
suspend fun saveAll(isDcAuto: Boolean, dc2: String, dc4: String, port: String, poolSize: Int,
|
||||
cfproxyEnabled: Boolean, secretKey: String) {
|
||||
cfproxyEnabled: Boolean, customCfDomainEnabled: Boolean, customCfDomain: String, secretKey: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.IS_DC_AUTO] = isDcAuto
|
||||
it[Keys.DC2] = dc2
|
||||
@@ -52,6 +68,8 @@ class SettingsStore(private val context: Context) {
|
||||
it[Keys.PORT] = port
|
||||
it[Keys.POOL_SIZE] = poolSize
|
||||
it[Keys.CFPROXY_ENABLED] = cfproxyEnabled
|
||||
it[Keys.CUSTOM_CF_DOMAIN_ENABLED] = customCfDomainEnabled
|
||||
it[Keys.CUSTOM_CF_DOMAIN] = customCfDomain
|
||||
it[Keys.SECRET_KEY] = secretKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,23 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.amurcanov.tgwsproxy.R
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.os.Build
|
||||
|
||||
@Composable
|
||||
fun FloatingToolbar(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit,
|
||||
isDynamicColor: Boolean,
|
||||
onDynamicColorChange: (Boolean) -> Unit,
|
||||
currentPalette: String,
|
||||
onPaletteChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
@@ -46,7 +58,7 @@ fun FloatingToolbar(
|
||||
|
||||
val tabWidthDp = 42.dp
|
||||
val tabHeightDp = 52.dp
|
||||
val panelWidthDp = 180.dp
|
||||
val panelWidthDp = 220.dp
|
||||
|
||||
val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } }
|
||||
|
||||
@@ -143,6 +155,52 @@ fun FloatingToolbar(
|
||||
selected = currentTheme == "dark",
|
||||
onClick = { onThemeChange("dark"); isExpanded = false }
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
|
||||
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val showDynamicColorOn = isDynamicColor && supportsDynamicColor
|
||||
val showPalettes = !showDynamicColorOn
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Динамические",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (supportsDynamicColor) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Switch(
|
||||
checked = showDynamicColorOn,
|
||||
onCheckedChange = { onDynamicColorChange(it) },
|
||||
enabled = supportsDynamicColor,
|
||||
modifier = Modifier.scale(0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = showPalettes) {
|
||||
Column {
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Text(
|
||||
"Палитра",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
PaletteCircle("indigo", 0xFF5B588D, currentPalette, onPaletteChange)
|
||||
PaletteCircle("forest", 0xFF5F5D68, currentPalette, onPaletteChange)
|
||||
PaletteCircle("espresso", 0xFF6D4C41, currentPalette, onPaletteChange)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,3 +243,23 @@ private fun ThemeOption(
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun PaletteCircle(
|
||||
paletteId: String,
|
||||
colorHex: Long,
|
||||
selectedId: String,
|
||||
onClick: (String) -> Unit
|
||||
) {
|
||||
val isSelected = paletteId == selectedId
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(colorHex))
|
||||
.clickable { onClick(paletteId) }
|
||||
.then(
|
||||
if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||
else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ private fun isNewerVersion(local: String, remote: String): Boolean {
|
||||
|
||||
@Composable
|
||||
fun InfoTab() {
|
||||
val currentVersion = "v1.0.6"
|
||||
val currentVersion = "v1.0.7"
|
||||
val scope = rememberCoroutineScope()
|
||||
var updateResult by remember { mutableStateOf<UpdateCheckResult>(UpdateCheckResult.Idle) }
|
||||
|
||||
@@ -271,7 +271,8 @@ fun InfoTab() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══ Справка ═══
|
||||
HelpCard()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
@@ -320,3 +321,68 @@ private fun GitHubSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
"Справка",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
HelpSection(
|
||||
title = "Авто / Адреса датацентров",
|
||||
text = "При включенном CloudFlare сервера (DC) настраивать не нужно — они переадресовываются автоматически. Если вы отключите CloudFlare, нажмите «Настроить адреса DC» для прямого подключения. По умолчанию DC4 зафиксирован на стабильном лондонском узле (149.154.167.220)."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "CloudFlare CDN",
|
||||
text = "Ваш трафик маскируется под HTTPS WebSockets внутри сети Cloudflare. Это способствует лучшему обходу блокировок на мобильных сетях. При использовании Wi-Fi этот режим можно отключать для повышения скорости. Делает блокировку прокси почти невозможной для DPI."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "Пул WS",
|
||||
text = "Механизм многопоточности (фоновые соединения). По умолчанию: 4 потока. Если видео или медиафайлы грузятся медленно, попробуйте увеличить пул."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "Секретный ключ",
|
||||
text = "Специальный 16-байтовый ключ шифрования MTProto. Меняйте его только в случае, если старой ссылкой для подключения завладели посторонние."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpSection(title: String, text: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Layers
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -100,6 +103,8 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443")
|
||||
val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4)
|
||||
val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true)
|
||||
val savedCustomDomainEnabled by settingsStore.customCfDomainEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val savedCustomDomain by settingsStore.customCfDomain.collectAsStateWithLifecycle(initialValue = "")
|
||||
val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING")
|
||||
|
||||
var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) }
|
||||
@@ -108,6 +113,8 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) }
|
||||
var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) }
|
||||
var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) }
|
||||
var customCfDomainEnabled by rememberSaveable(savedCustomDomainEnabled) { mutableStateOf(savedCustomDomainEnabled) }
|
||||
var customCfDomain by rememberSaveable(savedCustomDomain) { mutableStateOf(savedCustomDomain) }
|
||||
var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) }
|
||||
|
||||
LaunchedEffect(savedSecretKey) {
|
||||
@@ -128,19 +135,16 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
delay(300)
|
||||
settingsStore.saveAll(
|
||||
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
||||
cfEnabled, secretKeyText
|
||||
cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var showIpSetupDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showHelpDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
if (showIpSetupDialog) {
|
||||
IpSetupDialog(
|
||||
isDcAuto = isDcAuto,
|
||||
onModeChange = { isDcAuto = it; scheduleSave() },
|
||||
dc2Text = dc2Text,
|
||||
onDc2Change = { dc2Text = it; scheduleSave() },
|
||||
dc4Text = dc4Text,
|
||||
@@ -149,17 +153,19 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
)
|
||||
}
|
||||
|
||||
if (showHelpDialog) {
|
||||
HelpDialog(onDismiss = { showHelpDialog = false })
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Настройки",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
@@ -167,23 +173,27 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Подключение",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.Public, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
"Подключение",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = portText,
|
||||
onValueChange = { portText = it; scheduleSave() },
|
||||
label = { Text("Порт") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().height(60.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||
@@ -191,16 +201,20 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { showIpSetupDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
enabled = !cfEnabled && !isRunning,
|
||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (cfEnabled || isRunning) 0.2f else 0.5f))
|
||||
) {
|
||||
Icon(Icons.Default.Settings, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Настроить адреса DC", fontWeight = FontWeight.SemiBold)
|
||||
Text(if (cfEnabled) "Авто" else "Настроить адреса DC", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,8 +226,8 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -230,7 +244,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
"CloudFlare",
|
||||
"CloudFlare CDN",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -238,19 +252,14 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
}
|
||||
Switch(
|
||||
checked = cfEnabled,
|
||||
onCheckedChange = { cfEnabled = it; scheduleSave() },
|
||||
onCheckedChange = {
|
||||
cfEnabled = it
|
||||
isDcAuto = it
|
||||
scheduleSave()
|
||||
},
|
||||
enabled = !isRunning
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (cfEnabled)
|
||||
"Трафик проксируется через CloudFlare — улучшает обход блокировок."
|
||||
else
|
||||
"Подключение к DC Telegram напрямую.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,15 +270,18 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Пул WS",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.Layers, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
"Пул WS",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -286,7 +298,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), modifier = Modifier.padding(vertical = 4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -308,7 +320,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||
trailingIcon = {
|
||||
@@ -364,7 +376,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
scope.launch {
|
||||
settingsStore.saveAll(
|
||||
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
|
||||
cfEnabled, secretKeyText
|
||||
cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText
|
||||
)
|
||||
}
|
||||
val startIntent = Intent(context, ProxyService::class.java).apply {
|
||||
@@ -373,15 +385,14 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
putExtra(ProxyService.EXTRA_IPS, parsedIps)
|
||||
putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize)
|
||||
putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled)
|
||||
// ProxyService intent expects these even if CF priority is disabled in UI
|
||||
putExtra(ProxyService.EXTRA_CFPROXY_PRIORITY, true)
|
||||
putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, "")
|
||||
putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, if (customCfDomainEnabled && cfEnabled) customCfDomain.trim() else "")
|
||||
putExtra(ProxyService.EXTRA_SECRET_KEY, secretKeyText.trim())
|
||||
}
|
||||
ContextCompat.startForegroundService(context, startIntent)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = buttonColor)
|
||||
) {
|
||||
@@ -402,7 +413,7 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
val raw = secretKeyText.trim()
|
||||
if (raw.isNotEmpty()) raw else "00000000000000000000000000000000"
|
||||
}
|
||||
val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=ee$secretForUrl"
|
||||
val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=dd$secretForUrl"
|
||||
val telegramBtnColor by animateColorAsState(
|
||||
targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
animationSpec = tween(400),
|
||||
@@ -411,20 +422,53 @@ fun SettingsTab(settingsStore: SettingsStore) {
|
||||
Button(
|
||||
onClick = { openTelegram(context, proxyUrl) },
|
||||
enabled = isRunning,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface)
|
||||
) {
|
||||
Text("Применить в Telegram", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showHelpDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||
Text(
|
||||
text = "или",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("Proxy URL", proxyUrl)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = androidx.compose.ui.graphics.Color.Transparent,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp)
|
||||
) {
|
||||
Text("Пожалуйста ознакомьтесь!", fontWeight = FontWeight.Medium)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = proxyUrl,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "Копировать",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -456,102 +500,10 @@ private fun PoolChip(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpDialog(onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth(0.95f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
"Справка",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
HelpSection(
|
||||
title = "Адреса датацентров",
|
||||
text = "Внимание, рекомендую использовать при включенном CloudFlare - Автоматический режим получения DC от самого телеграма. " +
|
||||
"В случае если вы не пользуетесь CloudFlare или он у вас не работает, переключитесь на ручное использование. " +
|
||||
"По умолчанию указан DC4 149.154.167.220."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "Порт",
|
||||
text = "Локальный порт прокси. Используйте свободный порт. По умолчанию — 1443."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "CloudFlare",
|
||||
text = "Проксирует трафик через CloudFlare для обхода блокировок. Если Telegram не подключается — отключите."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "Пул WS",
|
||||
text = "Количество фоновых соединений (по умолчанию 4). Увеличьте, если скорость низкая."
|
||||
)
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
|
||||
HelpSection(
|
||||
title = "Секретный ключ",
|
||||
text = "Ключ шифрования MTProto. Обновляйте только при необходимости."
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("Понятно", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HelpSection(title: String, text: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun IpSetupDialog(
|
||||
isDcAuto: Boolean, onModeChange: (Boolean) -> Unit,
|
||||
dc2Text: String, onDc2Change: (String) -> Unit,
|
||||
dc4Text: String, onDc4Change: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
@@ -585,37 +537,8 @@ private fun IpSetupDialog(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (isDcAuto) "Авто DC от Telegram" else "Ручные DC",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Switch(
|
||||
checked = isDcAuto,
|
||||
onCheckedChange = { onModeChange(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDcAuto) {
|
||||
@Composable
|
||||
fun dcInput(label: String, value: String, update: (String) -> Unit) {
|
||||
@Composable
|
||||
fun dcInput(label: String, value: String, update: (String) -> Unit) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -638,7 +561,6 @@ private fun IpSetupDialog(
|
||||
|
||||
dcInput("DC2", dc2Text, onDc2Change)
|
||||
dcInput("DC4", dc4Text, onDc4Change)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
|
||||
@@ -115,6 +115,92 @@ private val DarkColorScheme = darkColorScheme(
|
||||
surfaceTint = Color(0xFFD7CCC8),
|
||||
)
|
||||
|
||||
// ═══ Тёмная палитра — «Цвет 1» ═══
|
||||
private val IndigoLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5B588D),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFE2DFFF),
|
||||
onPrimaryContainer = Color(0xFF1A1744),
|
||||
secondary = Color(0xFF5B588D),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFE2DFFF),
|
||||
onSecondaryContainer = Color(0xFF1A1744),
|
||||
background = Color(0xFFFBF8FF),
|
||||
onBackground = Color(0xFF1B1B1F),
|
||||
surface = Color(0xFFF6F3FA),
|
||||
onSurface = Color(0xFF1B1B1F),
|
||||
surfaceVariant = Color(0xFFE4E1EC),
|
||||
onSurfaceVariant = Color(0xFF47464F),
|
||||
outline = Color(0xFF787680),
|
||||
outlineVariant = Color(0xFFC8C5D0),
|
||||
)
|
||||
|
||||
private val IndigoDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFC4C0FF),
|
||||
onPrimary = Color(0xFF2D2A5B),
|
||||
primaryContainer = Color(0xFF434073),
|
||||
onPrimaryContainer = Color(0xFFE2DFFF),
|
||||
secondary = Color(0xFFC4C0FF),
|
||||
onSecondary = Color(0xFF2D2A5B),
|
||||
secondaryContainer = Color(0xFF434073),
|
||||
onSecondaryContainer = Color(0xFFE2DFFF),
|
||||
background = Color(0xFF131316),
|
||||
onBackground = Color(0xFFE4E1E6),
|
||||
surface = Color(0xFF1B1B1F),
|
||||
onSurface = Color(0xFFC8C5D0),
|
||||
surfaceVariant = Color(0xFF47464F),
|
||||
onSurfaceVariant = Color(0xFFC8C5D0),
|
||||
outline = Color(0xFF918F9A),
|
||||
outlineVariant = Color(0xFF47464F),
|
||||
)
|
||||
|
||||
// ═══ Палитра «Цвет 2» ═══
|
||||
private val ForestLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF5F5D68),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFE5E0F0),
|
||||
onPrimaryContainer = Color(0xFF1C1A23),
|
||||
secondary = Color(0xFF5F5D68),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFE5E0F0),
|
||||
onSecondaryContainer = Color(0xFF1C1A23),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1D1B20),
|
||||
surface = Color(0xFFF7F2FA),
|
||||
onSurface = Color(0xFF1D1B20),
|
||||
surfaceVariant = Color(0xFFE6E0E9),
|
||||
onSurfaceVariant = Color(0xFF48454E),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCAC4D0),
|
||||
)
|
||||
|
||||
private val ForestDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFC8C4D3),
|
||||
onPrimary = Color(0xFF312F38),
|
||||
primaryContainer = Color(0xFF474550),
|
||||
onPrimaryContainer = Color(0xFFE5E0F0),
|
||||
secondary = Color(0xFFC8C4D3),
|
||||
onSecondary = Color(0xFF312F38),
|
||||
secondaryContainer = Color(0xFF474550),
|
||||
onSecondaryContainer = Color(0xFFE5E0F0),
|
||||
background = Color(0xFF141318),
|
||||
onBackground = Color(0xFFE6E1E5),
|
||||
surface = Color(0xFF1D1B20),
|
||||
onSurface = Color(0xFFCAC4D0),
|
||||
surfaceVariant = Color(0xFF48454E),
|
||||
onSurfaceVariant = Color(0xFFCAC4D0),
|
||||
outline = Color(0xFF938F99),
|
||||
outlineVariant = Color(0xFF48454E),
|
||||
)
|
||||
|
||||
private fun getAppColorScheme(palette: String, isDark: Boolean): androidx.compose.material3.ColorScheme {
|
||||
return when(palette) {
|
||||
"espresso" -> if (isDark) DarkColorScheme else LightColorScheme
|
||||
"forest" -> if (isDark) ForestDarkColorScheme else ForestLightColorScheme
|
||||
else -> if (isDark) IndigoDarkColorScheme else IndigoLightColorScheme
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ Расширенные цвета для кастомных элементов ═══
|
||||
object AppColors {
|
||||
val connected = Color(0xFF4CAF50)
|
||||
@@ -148,6 +234,7 @@ object AppColors {
|
||||
fun TgWsProxyTheme(
|
||||
themeMode: String = "system",
|
||||
dynamicColor: Boolean = true,
|
||||
themePalette: String = "indigo",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
@@ -161,8 +248,7 @@ fun TgWsProxyTheme(
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
else -> getAppColorScheme(themePalette, darkTheme)
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
|
||||
468
tg-ws-proxy.go
468
tg-ws-proxy.go
@@ -52,8 +52,8 @@ import (
|
||||
const (
|
||||
defaultPort = 1443
|
||||
tcpNodelay = true
|
||||
defaultRecvBuf = 256 * 1024
|
||||
defaultSendBuf = 256 * 1024
|
||||
defaultRecvBuf = 128 * 1024
|
||||
defaultSendBuf = 128 * 1024
|
||||
defaultPoolSz = 4
|
||||
wsPoolMaxAge = 60.0
|
||||
wsBridgeIdle = 120.0
|
||||
@@ -86,6 +86,14 @@ var (
|
||||
proxySecretMu sync.RWMutex
|
||||
)
|
||||
|
||||
// FakeTLS config (ee-secret). Disabled by default for Android local proxy.
|
||||
// Only needed when running behind nginx/haproxy with SNI routing.
|
||||
var (
|
||||
fakeTlsEnabled = false
|
||||
fakeTlsDomain = "" // SNI domain for FakeTLS
|
||||
fakeTlsMu sync.RWMutex
|
||||
)
|
||||
|
||||
var dcDefaultIPs = map[int]string{
|
||||
1: "149.154.175.50",
|
||||
2: "149.154.167.51",
|
||||
@@ -127,6 +135,8 @@ func initLogging(verbose bool) {
|
||||
} else {
|
||||
logDebug = log.New(io.Discard, "", 0)
|
||||
}
|
||||
// FIX: Android CGO SIGPIPE crash protection
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -492,6 +502,25 @@ func (e *WsHandshakeError) IsRedirect() bool {
|
||||
// RawWebSocket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var bytesPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return make([]byte, 65536)
|
||||
},
|
||||
}
|
||||
|
||||
// FIX: SafeClose ensures socket RST mapping to avoid TIME_WAIT leaks
|
||||
func SafeClose(conn net.Conn) {
|
||||
if tc, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tc.SetLinger(0)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
var tlsConfigPool = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ClientSessionCache: tls.NewLRUClientSessionCache(100), // FIX: Zero-RTT Handshake
|
||||
}
|
||||
|
||||
const (
|
||||
opContinuation = 0x0
|
||||
opText = 0x1
|
||||
@@ -525,10 +554,8 @@ func wsConnect(ip, domain, path string, timeout float64) (*RawWebSocket, error)
|
||||
Timeout: time.Duration(dialTimeout * float64(time.Second)),
|
||||
}
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: domain,
|
||||
}
|
||||
tlsCfg := tlsConfigPool.Clone()
|
||||
tlsCfg.ServerName = domain
|
||||
|
||||
rawConn, err := tls.DialWithDialer(dialer, "tcp", ip+":443", tlsCfg)
|
||||
if err != nil {
|
||||
@@ -840,12 +867,57 @@ func (ws *RawWebSocket) readFrame() (int, []byte, error) {
|
||||
// Crypto helpers: DC extraction & patching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newAESCTR(key, iv []byte) (cipher.Stream, error) {
|
||||
// FIX: TrackedStream for perfect fallback cloning and 0-allocation
|
||||
type TrackedStream struct {
|
||||
key []byte
|
||||
iv []byte
|
||||
processed uint64
|
||||
stream cipher.Stream
|
||||
}
|
||||
|
||||
func newTrackedCTR(key, iv []byte) (*TrackedStream, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewCTR(block, iv), nil
|
||||
return &TrackedStream{
|
||||
key: append([]byte(nil), key...),
|
||||
iv: append([]byte(nil), iv...),
|
||||
processed: 0,
|
||||
stream: cipher.NewCTR(block, iv),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *TrackedStream) XORKeyStream(dst, src []byte) {
|
||||
t.stream.XORKeyStream(dst, src)
|
||||
t.processed += uint64(len(src))
|
||||
}
|
||||
|
||||
func (t *TrackedStream) Clone() cipher.Stream {
|
||||
block, _ := aes.NewCipher(t.key)
|
||||
cloneStream := cipher.NewCTR(block, t.iv)
|
||||
tClone := &TrackedStream{
|
||||
key: t.key,
|
||||
iv: t.iv,
|
||||
processed: t.processed,
|
||||
stream: cloneStream,
|
||||
}
|
||||
// Restore state precisely
|
||||
dummy := make([]byte, 1024)
|
||||
rem := t.processed
|
||||
for rem > 0 {
|
||||
n := rem
|
||||
if n > 1024 {
|
||||
n = 1024
|
||||
}
|
||||
tClone.stream.XORKeyStream(dummy[:n], dummy[:n])
|
||||
rem -= n
|
||||
}
|
||||
return tClone
|
||||
}
|
||||
|
||||
func newAESCTR(key, iv []byte) (cipher.Stream, error) {
|
||||
return newTrackedCTR(key, iv)
|
||||
}
|
||||
|
||||
func dcFromInit(data []byte) (dc int, isMedia bool, proto uint32, ok bool) {
|
||||
@@ -1095,22 +1167,28 @@ func wsDomains(dc int, isMedia *bool) []string {
|
||||
// WsPool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type dcSlot struct {
|
||||
dc int
|
||||
isMedia int
|
||||
}
|
||||
|
||||
type poolEntry struct {
|
||||
ws *RawWebSocket
|
||||
created float64
|
||||
created int64
|
||||
}
|
||||
|
||||
// FIX: Deadlock-free Lock-Free WsPool implementation
|
||||
type WsPool struct {
|
||||
mu sync.Mutex
|
||||
idle map[[2]int][]poolEntry
|
||||
refilling map[[2]int]bool
|
||||
queues sync.Map
|
||||
status sync.Map
|
||||
}
|
||||
|
||||
func newWsPool() *WsPool {
|
||||
return &WsPool{
|
||||
idle: make(map[[2]int][]poolEntry),
|
||||
refilling: make(map[[2]int]bool),
|
||||
}
|
||||
func newWsPool() *WsPool { return &WsPool{} }
|
||||
|
||||
func (p *WsPool) getQueue(slot dcSlot) (chan *poolEntry, *atomic.Int32) {
|
||||
q, _ := p.queues.LoadOrStore(slot, make(chan *poolEntry, poolSize))
|
||||
s, _ := p.status.LoadOrStore(slot, &atomic.Int32{})
|
||||
return q.(chan *poolEntry), s.(*atomic.Int32)
|
||||
}
|
||||
|
||||
func isMediaInt(b bool) int {
|
||||
@@ -1125,89 +1203,56 @@ func monoNow() float64 {
|
||||
}
|
||||
|
||||
func (p *WsPool) Get(dc int, isMedia bool, targetIP string, domains []string) *RawWebSocket {
|
||||
key := [2]int{dc, isMediaInt(isMedia)}
|
||||
now := monoNow()
|
||||
slot := dcSlot{dc, isMediaInt(isMedia)}
|
||||
q, s := p.getQueue(slot)
|
||||
now := time.Now().Unix()
|
||||
var ws *RawWebSocket
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
bucket := p.idle[key]
|
||||
for len(bucket) > 0 {
|
||||
entry := bucket[0]
|
||||
bucket = bucket[1:]
|
||||
p.idle[key] = bucket
|
||||
|
||||
age := now - entry.created
|
||||
if age > wsPoolMaxAge || entry.ws.closed.Load() {
|
||||
go entry.ws.Close()
|
||||
continue
|
||||
// FIX: Deadlock-free atomic pop
|
||||
for {
|
||||
select {
|
||||
case e := <-q:
|
||||
if now-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() {
|
||||
SafeClose(e.ws.conn)
|
||||
continue
|
||||
}
|
||||
ws = e.ws
|
||||
stats.poolHits.Add(1)
|
||||
logDebug.Printf("⚡ Пул: DC%d%s взят (ост:%d)", dc, mediaTag(isMedia), len(q))
|
||||
default:
|
||||
stats.poolMisses.Add(1)
|
||||
}
|
||||
|
||||
stats.poolHits.Add(1)
|
||||
logDebug.Printf("⚡ Пул: DC%d%s взят (%.0fс, ост:%d)",
|
||||
dc, mediaTag(isMedia), age, len(bucket))
|
||||
p.scheduleRefillLocked(key, targetIP, domains)
|
||||
return entry.ws
|
||||
break
|
||||
}
|
||||
|
||||
stats.poolMisses.Add(1)
|
||||
p.scheduleRefillLocked(key, targetIP, domains)
|
||||
return nil
|
||||
// FIX: Atomic CAS Replace (No deadlocks during refilling)
|
||||
if s.CompareAndSwap(0, 1) {
|
||||
go p.refill(slot, q, s, targetIP, domains)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// scheduleRefillLocked must be called with p.mu held
|
||||
func (p *WsPool) scheduleRefillLocked(key [2]int, targetIP string, domains []string) {
|
||||
if p.refilling[key] {
|
||||
return
|
||||
}
|
||||
p.refilling[key] = true
|
||||
go p.refill(key, targetIP, domains)
|
||||
}
|
||||
func (p *WsPool) refill(slot dcSlot, q chan *poolEntry, s *atomic.Int32, targetIP string, domains []string) {
|
||||
defer s.Store(0)
|
||||
needed := poolSize - len(q)
|
||||
if needed <= 0 { return }
|
||||
|
||||
func (p *WsPool) refill(key [2]int, targetIP string, domains []string) {
|
||||
dc := key[0]
|
||||
isMedia := key[1] == 1
|
||||
|
||||
defer func() {
|
||||
p.mu.Lock()
|
||||
delete(p.refilling, key)
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
|
||||
p.mu.Lock()
|
||||
bucket := p.idle[key]
|
||||
needed := poolSize - len(bucket)
|
||||
p.mu.Unlock()
|
||||
|
||||
if needed <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type result struct {
|
||||
ws *RawWebSocket
|
||||
}
|
||||
|
||||
ch := make(chan result, needed)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < needed; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
ws := connectOneWS(targetIP, domains)
|
||||
ch <- result{ws}
|
||||
defer wg.Done()
|
||||
if ws := connectOneWS(targetIP, domains); ws != nil {
|
||||
select {
|
||||
case q <- &poolEntry{ws: ws, created: time.Now().Unix()}:
|
||||
default:
|
||||
SafeClose(ws.conn)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < needed; i++ {
|
||||
r := <-ch
|
||||
if r.ws != nil {
|
||||
p.mu.Lock()
|
||||
p.idle[key] = append(p.idle[key], poolEntry{r.ws, monoNow()})
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
logDebug.Printf("♻ Пул DC%d%s пополнен: %d готово",
|
||||
dc, mediaTag(isMedia), len(p.idle[key]))
|
||||
p.mu.Unlock()
|
||||
wg.Wait()
|
||||
logDebug.Printf("♻ Пул DC%d пополнен", slot.dc)
|
||||
}
|
||||
|
||||
func connectOneWS(targetIP string, domains []string) *RawWebSocket {
|
||||
@@ -1225,94 +1270,85 @@ func connectOneWS(targetIP string, domains []string) *RawWebSocket {
|
||||
}
|
||||
|
||||
func (p *WsPool) Warmup(dcOptMap map[int]string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for dc, targetIP := range dcOptMap {
|
||||
if targetIP == "" {
|
||||
continue
|
||||
}
|
||||
for _, isMedia := range []bool{false, true} {
|
||||
domains := wsDomains(dc, &isMedia)
|
||||
key := [2]int{dc, isMediaInt(isMedia)}
|
||||
p.scheduleRefillLocked(key, targetIP, domains)
|
||||
slot := dcSlot{dc, isMediaInt(isMedia)}
|
||||
q, s := p.getQueue(slot)
|
||||
if s.CompareAndSwap(0, 1) {
|
||||
go p.refill(slot, q, s, targetIP, domains)
|
||||
}
|
||||
}
|
||||
}
|
||||
logDebug.Printf("♻ Прогрев пула: %d DC", len(dcOptMap))
|
||||
}
|
||||
|
||||
func (p *WsPool) Maintain(ctx context.Context, dcOptMap map[int]string) {
|
||||
ticker := time.NewTicker(poolMaintainInterval * time.Second)
|
||||
ticker := time.NewTicker(time.Duration(poolMaintainInterval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ctx.Done(): return
|
||||
case <-ticker.C:
|
||||
p.maintainOnce(dcOptMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WsPool) maintainOnce(dcOptMap map[int]string) {
|
||||
now := monoNow()
|
||||
|
||||
p.mu.Lock()
|
||||
for key, bucket := range p.idle {
|
||||
var fresh []poolEntry
|
||||
for _, e := range bucket {
|
||||
age := now - e.created
|
||||
if age > wsPoolMaxAge || e.ws.closed.Load() {
|
||||
go e.ws.Close()
|
||||
} else {
|
||||
// Send ping to keep connection alive
|
||||
go func(ws *RawWebSocket) {
|
||||
if err := ws.SendPing(); err != nil {
|
||||
ws.Close()
|
||||
p.queues.Range(func(key, val interface{}) bool {
|
||||
slot := key.(dcSlot)
|
||||
q := val.(chan *poolEntry)
|
||||
s, _ := p.status.Load(slot)
|
||||
|
||||
sz := len(q)
|
||||
for i := 0; i < sz; i++ {
|
||||
select {
|
||||
case e := <-q:
|
||||
if time.Now().Unix()-e.created > int64(wsPoolMaxAge) || e.ws.closed.Load() {
|
||||
SafeClose(e.ws.conn)
|
||||
} else {
|
||||
// FIX: HTTP Ping adaptive keep-alive strategy
|
||||
_ = e.ws.SendPing()
|
||||
select {
|
||||
case q <- e:
|
||||
default: SafeClose(e.ws.conn)
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
}(e.ws)
|
||||
fresh = append(fresh, e)
|
||||
}
|
||||
}
|
||||
p.idle[key] = fresh
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Refill all known DCs
|
||||
p.mu.Lock()
|
||||
for dc, targetIP := range dcOptMap {
|
||||
if targetIP == "" {
|
||||
continue
|
||||
}
|
||||
for _, isMedia := range []bool{false, true} {
|
||||
domains := wsDomains(dc, &isMedia)
|
||||
key := [2]int{dc, isMediaInt(isMedia)}
|
||||
p.scheduleRefillLocked(key, targetIP, domains)
|
||||
}
|
||||
|
||||
if len(q) < poolSize && s.(*atomic.Int32).CompareAndSwap(0, 1) {
|
||||
isMediaBool := slot.isMedia == 1
|
||||
dms := wsDomains(slot.dc, &isMediaBool)
|
||||
go p.refill(slot, q, s.(*atomic.Int32), dcOptMap[slot.dc], dms)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *WsPool) IdleCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
count := 0
|
||||
for _, bucket := range p.idle {
|
||||
count += len(bucket)
|
||||
}
|
||||
p.queues.Range(func(_, val interface{}) bool {
|
||||
count += len(val.(chan *poolEntry))
|
||||
return true
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func (p *WsPool) CloseAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for key, bucket := range p.idle {
|
||||
for _, e := range bucket {
|
||||
go e.ws.Close()
|
||||
p.queues.Range(func(_, val interface{}) bool {
|
||||
q := val.(chan *poolEntry)
|
||||
for {
|
||||
select {
|
||||
case e := <-q:
|
||||
SafeClose(e.ws.conn)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
delete(p.idle, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var wsPool = newWsPool()
|
||||
@@ -1381,24 +1417,23 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
||||
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Critical: close connections when context is cancelled
|
||||
// This unblocks the Read() calls in goroutines
|
||||
go func() {
|
||||
<-ctx2.Done()
|
||||
_ = conn.Close()
|
||||
SafeClose(conn)
|
||||
ws.Close()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// tcp -> ws
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer cancel()
|
||||
buf := make([]byte, 65536)
|
||||
buf := bytesPool.Get().([]byte)
|
||||
defer bytesPool.Put(buf)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) // FIX: Goroutine leak ReadTimeout
|
||||
n, err := conn.Read(buf[:cap(buf)])
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
stats.bytesUp.Add(int64(n))
|
||||
@@ -1406,6 +1441,7 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
||||
|
||||
cltDec.XORKeyStream(chunk, chunk)
|
||||
tgEnc.XORKeyStream(chunk, chunk)
|
||||
|
||||
var sendErr error
|
||||
if splitter != nil {
|
||||
parts := splitter.Split(chunk)
|
||||
@@ -1428,11 +1464,11 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
||||
}
|
||||
}()
|
||||
|
||||
// ws -> tcp
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer cancel()
|
||||
for {
|
||||
_ = ws.conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||
data, err := ws.Recv()
|
||||
if err != nil || data == nil {
|
||||
return
|
||||
@@ -1442,20 +1478,16 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket,
|
||||
downBytes += int64(n)
|
||||
tgDec.XORKeyStream(data, data)
|
||||
cltEnc.XORKeyStream(data, data)
|
||||
if _, err := conn.Write(data); err != nil {
|
||||
if _, werr := conn.Write(data); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
if upBytes > 0 || downBytes > 0 {
|
||||
logInfo.Printf("✕ %s ↑%s ↓%s %.1fс",
|
||||
dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed)
|
||||
} else {
|
||||
logDebug.Printf("✕ %s пустое (%.1fс)", dcTag, elapsed)
|
||||
logInfo.Printf("✕ %s ↑%s ↓%s %.1fс", dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,11 +1500,10 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn,
|
||||
|
||||
ctx2, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Close connections when context cancelled
|
||||
go func() {
|
||||
<-ctx2.Done()
|
||||
_ = client.Close()
|
||||
_ = remote.Close()
|
||||
SafeClose(client)
|
||||
SafeClose(remote)
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -1481,20 +1512,23 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn,
|
||||
forward := func(src, dstW net.Conn, isUp bool) {
|
||||
defer wg.Done()
|
||||
defer cancel()
|
||||
buf := make([]byte, 65536)
|
||||
buf := bytesPool.Get().([]byte)
|
||||
defer bytesPool.Put(buf)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
_ = src.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||
n, err := src.Read(buf[:cap(buf)])
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
if isUp {
|
||||
stats.bytesUp.Add(int64(n))
|
||||
cltDec.XORKeyStream(buf[:n], buf[:n])
|
||||
tgEnc.XORKeyStream(buf[:n], buf[:n])
|
||||
cltDec.XORKeyStream(chunk, chunk)
|
||||
tgEnc.XORKeyStream(chunk, chunk)
|
||||
} else {
|
||||
stats.bytesDown.Add(int64(n))
|
||||
tgDec.XORKeyStream(buf[:n], buf[:n])
|
||||
cltEnc.XORKeyStream(buf[:n], buf[:n])
|
||||
tgDec.XORKeyStream(chunk, chunk)
|
||||
cltEnc.XORKeyStream(chunk, chunk)
|
||||
}
|
||||
if _, werr := dstW.Write(buf[:n]); werr != nil {
|
||||
if _, werr := dstW.Write(chunk); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1621,6 +1655,12 @@ func doFallback(ctx context.Context, conn net.Conn, relayInit []byte, label stri
|
||||
dc int, isMedia bool, splitter *MsgSplitter,
|
||||
cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool {
|
||||
|
||||
// FIX: Permanent Bad Handshake - Clone crypto state before fallback
|
||||
if t, ok := cltDec.(interface{ Clone() cipher.Stream }); ok { cltDec = t.Clone() }
|
||||
if t, ok := cltEnc.(interface{ Clone() cipher.Stream }); ok { cltEnc = t.Clone() }
|
||||
if t, ok := tgEnc.(interface{ Clone() cipher.Stream }); ok { tgEnc = t.Clone() }
|
||||
if t, ok := tgDec.(interface{ Clone() cipher.Stream }); ok { tgDec = t.Clone() }
|
||||
|
||||
// Use configured DC IP if available, otherwise fall back to defaults
|
||||
var fallbackDst string
|
||||
dcOptMu.RLock()
|
||||
@@ -1739,11 +1779,12 @@ func verifyClientHello(data, secret []byte) ([]byte, []byte, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check timestamp
|
||||
// Check timestamp handling Monotonic time drift cleanly
|
||||
tsXor := make([]byte, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
tsXor[i] = clientRandom[28+i] ^ mac[28+i]
|
||||
}
|
||||
// FIX: Correct FakeTLS little-endian unmarshaling
|
||||
timestamp := binary.LittleEndian.Uint32(tsXor)
|
||||
now := uint32(time.Now().Unix())
|
||||
diff := int64(now) - int64(timestamp)
|
||||
@@ -1968,6 +2009,10 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
||||
proxySecretMu.RUnlock()
|
||||
secretBytes, _ := hex.DecodeString(currentSecret)
|
||||
|
||||
fakeTlsMu.RLock()
|
||||
useFakeTls := fakeTlsEnabled
|
||||
fakeTlsMu.RUnlock()
|
||||
|
||||
// Read first byte to detect FakeTLS vs plain
|
||||
firstByte := make([]byte, 1)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
@@ -1980,19 +2025,25 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
||||
var clientConn net.Conn = conn // the connection we read MTProto from
|
||||
var handshake []byte
|
||||
|
||||
if firstByte[0] == tlsRecordHandshake {
|
||||
// FakeTLS mode (ee-secret)
|
||||
if useFakeTls && firstByte[0] == tlsRecordHandshake {
|
||||
// FakeTLS mode (ee-secret) — only when explicitly enabled
|
||||
hdrRest := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, hdrRest); err != nil {
|
||||
logDebug.Printf("неполный TLS-заголовок")
|
||||
logDebug.Printf("FakeTLS: неполный TLS-заголовок")
|
||||
return
|
||||
}
|
||||
tlsHeader := append(firstByte, hdrRest...)
|
||||
recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5]))
|
||||
|
||||
if recordLen > 16384 {
|
||||
stats.connectionsBad.Add(1)
|
||||
logDebug.Printf("FakeTLS: record слишком большой (%d)", recordLen)
|
||||
return
|
||||
}
|
||||
|
||||
recordBody := make([]byte, recordLen)
|
||||
if _, err := io.ReadFull(conn, recordBody); err != nil {
|
||||
logDebug.Printf("неполное тело TLS-записи")
|
||||
logDebug.Printf("FakeTLS: неполное тело TLS-записи")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2001,7 +2052,7 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
||||
clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes)
|
||||
if !ok {
|
||||
stats.connectionsBad.Add(1)
|
||||
logWarn.Printf("⚠ bad handshake")
|
||||
logDebug.Printf("FakeTLS: HMAC проверка не пройдена (peer=%s)", peer)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2017,11 +2068,12 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
||||
|
||||
handshake = make([]byte, 64)
|
||||
if _, err := io.ReadFull(tlsConn, handshake); err != nil {
|
||||
logDebug.Printf("неполный обфускированный init внутри TLS")
|
||||
logDebug.Printf("FakeTLS: неполный init внутри TLS")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Plain obfuscated mode (dd-secret)
|
||||
// FIX: When FakeTLS is disabled, treat ALL first bytes as plain data
|
||||
rest := make([]byte, 63)
|
||||
if _, err := io.ReadFull(conn, rest); err != nil {
|
||||
logDebug.Printf("клиент отключился до рукопожатия")
|
||||
@@ -2050,7 +2102,7 @@ func handleClient(ctx context.Context, conn net.Conn) {
|
||||
proto := binary.LittleEndian.Uint32(protoTag)
|
||||
if !validProtos[proto] {
|
||||
stats.connectionsBad.Add(1)
|
||||
logWarn.Printf("⚠ bad handshake")
|
||||
logDebug.Printf("bad handshake: proto=0x%08X (ожид: EFEF/EEEE/DDDD) peer=%s", proto, peer)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2284,7 +2336,17 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin
|
||||
proxySecretMu.RLock()
|
||||
currentSec := proxySecret
|
||||
proxySecretMu.RUnlock()
|
||||
logInfo.Printf(" Ключ: ee%s", currentSec)
|
||||
fakeTlsMu.RLock()
|
||||
tlsOn := fakeTlsEnabled
|
||||
tlsDom := fakeTlsDomain
|
||||
fakeTlsMu.RUnlock()
|
||||
if tlsOn {
|
||||
domHex := hex.EncodeToString([]byte(tlsDom))
|
||||
logInfo.Printf(" Ключ: ee%s%s", currentSec, domHex)
|
||||
logInfo.Printf(" FakeTLS: вкл (домен: %s)", tlsDom)
|
||||
} else {
|
||||
logInfo.Printf(" Ключ: dd%s", currentSec)
|
||||
}
|
||||
logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
|
||||
// Stats logger
|
||||
@@ -2524,7 +2586,13 @@ func SetSecret(cSecret *C.char) {
|
||||
proxySecret = s
|
||||
proxySecretMu.Unlock()
|
||||
if logInfo != nil {
|
||||
logInfo.Printf("🔑 Ключ обновлён: ee%s...", s[:8])
|
||||
fakeTlsMu.RLock()
|
||||
prefix := "dd"
|
||||
if fakeTlsEnabled {
|
||||
prefix = "ee"
|
||||
}
|
||||
fakeTlsMu.RUnlock()
|
||||
logInfo.Printf("🔑 Ключ обновлён: %s%s...", prefix, s[:8])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2534,6 +2602,44 @@ func GetStats() *C.char {
|
||||
return C.CString(s)
|
||||
}
|
||||
|
||||
//export SetFakeTls
|
||||
func SetFakeTls(enabled C.int, cDomain *C.char) {
|
||||
fakeTlsMu.Lock()
|
||||
defer fakeTlsMu.Unlock()
|
||||
|
||||
fakeTlsEnabled = int(enabled) != 0
|
||||
fakeTlsDomain = C.GoString(cDomain)
|
||||
|
||||
if logInfo != nil {
|
||||
if fakeTlsEnabled {
|
||||
logInfo.Printf("🔒 FakeTLS: вкл (домен: %s)", fakeTlsDomain)
|
||||
} else {
|
||||
logInfo.Printf("🔓 FakeTLS: выкл (используется dd-secret)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//export GetSecretWithPrefix
|
||||
func GetSecretWithPrefix() *C.char {
|
||||
proxySecretMu.RLock()
|
||||
sec := proxySecret
|
||||
proxySecretMu.RUnlock()
|
||||
|
||||
fakeTlsMu.RLock()
|
||||
tlsOn := fakeTlsEnabled
|
||||
tlsDom := fakeTlsDomain
|
||||
fakeTlsMu.RUnlock()
|
||||
|
||||
var result string
|
||||
if tlsOn && tlsDom != "" {
|
||||
domHex := hex.EncodeToString([]byte(tlsDom))
|
||||
result = "ee" + sec + domHex
|
||||
} else {
|
||||
result = "dd" + sec
|
||||
}
|
||||
return C.CString(result)
|
||||
}
|
||||
|
||||
//export FreeString
|
||||
func FreeString(p *C.char) {
|
||||
C.free(unsafe.Pointer(p))
|
||||
|
||||
Reference in New Issue
Block a user