3 Commits

Author SHA1 Message Date
amurcanov
4f3a14d828 Update build.gradle.kts 2026-04-14 02:27:17 +03:00
amurcanov
a43d61bc38 Update v1.0.7 2026-04-14 02:25:23 +03:00
amurcanov
d1ca465f83 Update README.md 2026-04-14 02:21:15 +03:00
11 changed files with 725 additions and 432 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
)
)
}

View File

@@ -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
)
}
}

View File

@@ -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))

View File

@@ -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(

View File

@@ -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))