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