Compare commits

...

26 Commits

Author SHA1 Message Date
Dark-Avery c3794ed024
Merge a5641700ed into da4b521aba 2026-03-30 14:37:33 +00:00
Dark_Avery a5641700ed test(android): cover bridge startup without cryptography 2026-03-30 17:18:44 +03:00
Dark_Avery 509f50fcae fix(android): avoid cryptography dependency and preserve version on update errors 2026-03-30 17:03:26 +03:00
Dark_Avery e511ff597b fix(runtime): detect android bind errors as port conflicts 2026-03-30 16:38:09 +03:00
Dark_Avery 3552de7dbf fix(gitignore): ignore WSL android build symlink 2026-03-30 16:34:57 +03:00
Dark_Avery 7c8bc17db6 fix(android-build): run gradle from android project root 2026-03-30 16:34:18 +03:00
Dark_Avery 76b375bd03 fix(runtime): close ws pool tasks before loop shutdown 2026-03-30 16:32:34 +03:00
Dark_Avery 7ad377c12c fix(android-build): move WSL build outputs off Windows mounts 2026-03-30 16:22:38 +03:00
Dark_Avery 7e9acc47fc docs(readme): keep android-only additions on android_migration 2026-03-30 16:17:44 +03:00
Dark_Avery 0302a3b817 docs(readme): align android instructions with mtproto config 2026-03-30 16:15:41 +03:00
Dark_Avery 810991ea18 feat(android): switch config and tg intent to mtproto model 2026-03-30 16:14:54 +03:00
Dark_Avery 1599b1126c feat(runtime): adapt android_migration shell to upstream mtproto core 2026-03-30 16:14:42 +03:00
Dark_Avery 9e2c8c16ff merge: take upstream v1.4.0 core as android_migration base 2026-03-30 16:14:29 +03:00
gogamlg3 da4b521aba
Изменение README для AUR (#485) 2026-03-30 09:55:44 +03:00
Flowseal 07facfe18c Version bump 2026-03-29 20:00:31 +03:00
Qirashi 7a886dff26
Update ctk_theme.py (#480) 2026-03-29 19:56:43 +03:00
Flowseal 17e37f9ca0 host detect in first-run window 2026-03-29 19:55:39 +03:00
Flowseal 968827445f copy link, mtproto new first run notify 2026-03-29 17:57:55 +03:00
Flowseal be8d178e5c secret validation 2026-03-29 17:30:39 +03:00
Dark_Avery 79840806f2 Merge remote-tracking branch 'origin/android_migration' into android_migration 2026-03-29 16:50:15 +03:00
Dark_Avery 68a378bad9 feat(android): harden update checks, ci, and direct-only ui 2026-03-29 16:45:20 +03:00
Flowseal 46426c45b0 ctk refactoring 2026-03-29 15:21:56 +03:00
Flowseal c4a044542c fixes 2026-03-29 15:21:45 +03:00
Flowseal af74009b11 icon fix 2026-03-28 16:48:59 +03:00
Flowseal 6766db9812 mtproto recode 2026-03-28 15:45:08 +03:00
Flowseal 95f99be26b old socks removed 2026-03-28 13:07:51 +03:00
32 changed files with 2092 additions and 2300 deletions

View File

@ -306,7 +306,7 @@ jobs:
Maintainer: Flowseal Maintainer: Flowseal
Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk Depends: libgtk-3-0, libayatana-appindicator3-1, python3-tk
Description: Telegram Desktop WebSocket Bridge Proxy Description: Telegram Desktop WebSocket Bridge Proxy
SOCKS5/WebSocket bridge proxy for Telegram Desktop with tray UI. MTProto/WebSocket bridge proxy for Telegram Desktop with tray UI.
EOF EOF
dpkg-deb --build --root-owner-group \ dpkg-deb --build --root-owner-group \

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ local.properties
android/.idea/ android/.idea/
android/build/ android/build/
android/app/build/ android/app/build/
android/app/build
android/*.jks android/*.jks
*.keystore *.keystore
android/*.keystore.properties android/*.keystore.properties

View File

@ -23,7 +23,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PATH=/opt/venv/bin:$PATH \ PATH=/opt/venv/bin:$PATH \
TG_WS_PROXY_HOST=0.0.0.0 \ TG_WS_PROXY_HOST=0.0.0.0 \
TG_WS_PROXY_PORT=1080 \ TG_WS_PROXY_PORT=1443 \
TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220" TG_WS_PROXY_DC_IPS="2:149.154.167.220 4:149.154.167.220"
RUN apt-get update \ RUN apt-get update \
@ -39,7 +39,7 @@ COPY README.md LICENSE ./
USER app USER app
EXPOSE 1080/tcp EXPOSE 1443/tcp
ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/bin/sh", "-lc", "set -eu; args=\"--host ${TG_WS_PROXY_HOST} --port ${TG_WS_PROXY_PORT}\"; for dc in ${TG_WS_PROXY_DC_IPS}; do args=\"$args --dc-ip $dc\"; done; exec python -u proxy/tg_ws_proxy.py $args \"$@\"", "--"]
CMD [] CMD []

View File

@ -12,17 +12,17 @@
# TG WS Proxy # TG WS Proxy
**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. **Локальный MTProto-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера.
<img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" /> <img width="529" height="487" alt="image" src="https://github.com/user-attachments/assets/6a4cf683-0df8-43af-86c1-0e8f08682b62" />
## Как это работает ## Как это работает
``` ```
Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegram DC Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram DC
``` ```
1. Приложение поднимает локальный SOCKS5-прокси на `127.0.0.1:1080` 1. Приложение поднимает MTProto прокси на `127.0.0.1:1443`
2. Перехватывает подключения к IP-адресам Telegram 2. Перехватывает подключения к IP-адресам Telegram
3. Извлекает DC ID из MTProto obfuscation init-пакета 3. Извлекает DC ID из MTProto obfuscation init-пакета
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram 4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
@ -38,7 +38,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS → Telegra
**Меню трея:** **Меню трея:**
- **Открыть в Telegram** — автоматически настроить прокси через `tg://socks` ссылку - **Открыть в Telegram** — автоматически настроить прокси через `tg://proxy` ссылку
- **Перезапустить прокси** — перезапуск без выхода из приложения - **Перезапустить прокси** — перезапуск без выхода из приложения
- **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub) - **Настройки...** — GUI-редактор конфигурации (в т.ч. версия приложения, опциональная проверка обновлений с GitHub)
- **Открыть логи** — открыть файл логов - **Открыть логи** — открыть файл логов
@ -69,8 +69,9 @@ makepkg -si
# При помощи AUR-helper # При помощи AUR-helper
paru -S tg-ws-proxy-bin paru -S tg-ws-proxy-bin
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси: # Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
sudo systemctl start tg-ws-proxy-cli@8888 # разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
``` ```
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64). Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
@ -103,7 +104,7 @@ chmod +x TgWsProxy_linux_amd64
### Консольный proxy ### Консольный proxy
Для запуска только SOCKS5/WebSocket proxy без tray-интерфейса достаточно базовой установки: Для запуска только proxy без tray-интерфейса достаточно базовой установки:
```bash ```bash
pip install -e . pip install -e .
@ -151,51 +152,19 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
android/app/build/outputs/apk/standard/debug/app-standard-debug.apk android/app/build/outputs/apk/standard/debug/app-standard-debug.apk
``` ```
Legacy32 debug-сборка:
```bash
./android/build-local-debug.sh assembleLegacy32Debug
```
Результат:
```text
android/app/build/outputs/apk/legacy32/debug/app-legacy32-debug.apk
```
### Android signed release APK
Для локальной release-сборки нужен keystore и переменные окружения:
```bash
export ANDROID_KEYSTORE_FILE=/path/to/tg-ws-proxy-release.keystore
export ANDROID_KEYSTORE_PASSWORD=...
export ANDROID_KEY_ALIAS=tg-ws-proxy
export ANDROID_KEY_PASSWORD=...
```
Сборка:
```bash
cd android
./build-local-debug.sh assembleStandardRelease
./build-local-debug.sh assembleLegacy32Release
```
Результат:
```text
android/app/build/outputs/apk/standard/release/app-standard-release.apk
android/app/build/outputs/apk/legacy32/release/app-legacy32-release.apk
```
**Аргументы:** **Аргументы:**
| Аргумент | По умолчанию | Описание | | Аргумент | По умолчанию | Описание |
|---|---|---| |---|---|---|
| `--port` | `1080` | Порт SOCKS5-прокси | | `--port` | `1443` | Порт прокси |
| `--host` | `127.0.0.1` | Хост SOCKS5-прокси | | `--host` | `127.0.0.1` | Хост прокси |
| `--secret` | `random` | 32 hex chars secret для авторизации клиентов |
| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) | | `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Целевой IP для DC (можно указать несколько раз) |
| `--buf-kb` | `256` | Размер буфера в КБ
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC
| `--log-file` | выкл. | Путь до файла, в который сохранять логи
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись)
| `--log-backups` | `0` | Количество сохранений логов после перезаписи
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) | | `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
**Примеры:** **Примеры:**
@ -235,10 +204,10 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси** 1. Telegram → **Настройки****Продвинутые настройки****Тип подключения** → **Прокси**
2. Добавить прокси: 2. Добавить прокси:
- **Тип:** SOCKS5 - **Тип:** MTProto
- **Сервер:** `127.0.0.1` - **Сервер:** `127.0.0.1` (или переопределенный вами)
- **Порт:** `1080` - **Порт:** `1443` (или переопределенный вами)
- **Логин/Пароль:** оставить пустыми - **Secret:** из настроек или логов
## Настройка Telegram Android ## Настройка Telegram Android
@ -250,10 +219,10 @@ tg-ws-proxy-tray-linux = "linux:main"
1. Telegram → **Настройки****Данные и память** → **Настройки прокси** 1. Telegram → **Настройки****Данные и память** → **Настройки прокси**
2. Добавить прокси: 2. Добавить прокси:
- **Тип:** SOCKS5 - **Тип:** MTProto
- **Сервер:** `127.0.0.1` - **Сервер:** `127.0.0.1`
- **Порт:** `1080` - **Порт:** `1443`
- **Логин/Пароль:** оставить пустыми - **Secret:** из настроек приложения
Важно: Важно:
@ -271,7 +240,8 @@ Tray-приложение хранит данные в:
```json ```json
{ {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 1080, "port": 1443,
"secret": "...",
"dc_ip": [ "dc_ip": [
"2:149.154.167.220", "2:149.154.167.220",
"4:149.154.167.220" "4:149.154.167.220"
@ -298,18 +268,6 @@ Tray-приложение хранит данные в:
- Intel macOS 10.15+ - Intel macOS 10.15+
- Apple Silicon macOS 11.0+ - Apple Silicon macOS 11.0+
- Linux x86_64 (требуется AppIndicator для системного трея) - Linux x86_64 (требуется AppIndicator для системного трея)
Android-артефакты:
- `tg-ws-proxy-android-vX.Y.Z.apk`
- `tg-ws-proxy-android-vX.Y.Z-legacy32.apk`
Для signed Android release в GitHub Actions нужны secrets:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
## Лицензия ## Лицензия
[MIT License](LICENSE) [MIT License](LICENSE)

View File

@ -128,6 +128,7 @@ class MainActivity : AppCompatActivity() {
private fun renderConfig(config: ProxyConfig) { private fun renderConfig(config: ProxyConfig) {
binding.hostInput.setText(config.host) binding.hostInput.setText(config.host)
binding.portInput.setText(config.portText) binding.portInput.setText(config.portText)
binding.secretInput.setText(config.secretText)
binding.dcIpInput.setText(config.dcIpText) binding.dcIpInput.setText(config.dcIpText)
binding.logMaxMbInput.setText(config.logMaxMbText) binding.logMaxMbInput.setText(config.logMaxMbText)
binding.bufferKbInput.setText(config.bufferKbText) binding.bufferKbInput.setText(config.bufferKbText)
@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
return ProxyConfig( return ProxyConfig(
host = binding.hostInput.text?.toString().orEmpty(), host = binding.hostInput.text?.toString().orEmpty(),
portText = binding.portInput.text?.toString().orEmpty(), portText = binding.portInput.text?.toString().orEmpty(),
secretText = binding.secretInput.text?.toString().orEmpty(),
dcIpText = binding.dcIpInput.text?.toString().orEmpty(), dcIpText = binding.dcIpInput.text?.toString().orEmpty(),
logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(), logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(),
bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(), bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(),
@ -151,7 +153,7 @@ class MainActivity : AppCompatActivity() {
} }
private fun onOpenReleasePageClicked() { private fun onOpenReleasePageClicked() {
val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Dark-Avery/tg-ws-proxy/releases/latest" val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
val opened = runCatching { val opened = runCatching {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}.isSuccess }.isSuccess
@ -168,7 +170,7 @@ class MainActivity : AppCompatActivity() {
} }
}.getOrElse { exc -> }.getOrElse { exc ->
ProxyUpdateStatus( ProxyUpdateStatus(
currentVersion = "unknown", currentVersion = currentAppVersionName(),
error = exc.message ?: exc.javaClass.simpleName, error = exc.message ?: exc.javaClass.simpleName,
) )
} }

View File

@ -1,8 +1,11 @@
package org.flowseal.tgwsproxy package org.flowseal.tgwsproxy
import java.security.SecureRandom
data class ProxyConfig( data class ProxyConfig(
val host: String = DEFAULT_HOST, val host: String = DEFAULT_HOST,
val portText: String = DEFAULT_PORT.toString(), val portText: String = DEFAULT_PORT.toString(),
val secretText: String = DEFAULT_SECRET,
val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"), val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"),
val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB), val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB),
val bufferKbText: String = DEFAULT_BUFFER_KB.toString(), val bufferKbText: String = DEFAULT_BUFFER_KB.toString(),
@ -22,6 +25,13 @@ data class ProxyConfig(
return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.") return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.")
} }
val secretValue = secretText.trim().lowercase()
if (secretValue.length != 32 || !secretValue.all { it in "0123456789abcdef" }) {
return ValidationResult(
errorMessage = "MTProto secret должен содержать ровно 32 hex-символа."
)
}
val lines = dcIpText val lines = dcIpText
.lineSequence() .lineSequence()
.map { it.trim() } .map { it.trim() }
@ -75,6 +85,7 @@ data class ProxyConfig(
normalized = NormalizedProxyConfig( normalized = NormalizedProxyConfig(
host = hostValue, host = hostValue,
port = portValue, port = portValue,
secret = secretValue,
dcIpList = lines, dcIpList = lines,
logMaxMb = logMaxMbValue, logMaxMb = logMaxMbValue,
bufferKb = bufferKbValue, bufferKb = bufferKbValue,
@ -87,10 +98,11 @@ data class ProxyConfig(
companion object { companion object {
const val DEFAULT_HOST = "127.0.0.1" const val DEFAULT_HOST = "127.0.0.1"
const val DEFAULT_PORT = 1080 const val DEFAULT_PORT = 1443
const val DEFAULT_LOG_MAX_MB = 5.0 const val DEFAULT_LOG_MAX_MB = 5.0
const val DEFAULT_BUFFER_KB = 256 const val DEFAULT_BUFFER_KB = 256
const val DEFAULT_POOL_SIZE = 4 const val DEFAULT_POOL_SIZE = 4
val DEFAULT_SECRET = generateSecret()
val DEFAULT_DC_IP_LINES = listOf( val DEFAULT_DC_IP_LINES = listOf(
"2:149.154.167.220", "2:149.154.167.220",
"4:149.154.167.220", "4:149.154.167.220",
@ -104,6 +116,12 @@ data class ProxyConfig(
} }
} }
private fun generateSecret(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return bytes.joinToString(separator = "") { "%02x".format(it) }
}
private fun isIpv4Address(value: String): Boolean { private fun isIpv4Address(value: String): Boolean {
val octets = value.split(".") val octets = value.split(".")
if (octets.size != 4) { if (octets.size != 4) {
@ -128,6 +146,7 @@ data class ValidationResult(
data class NormalizedProxyConfig( data class NormalizedProxyConfig(
val host: String, val host: String,
val port: Int, val port: Int,
val secret: String,
val dcIpList: List<String>, val dcIpList: List<String>,
val logMaxMb: Double, val logMaxMb: Double,
val bufferKb: Int, val bufferKb: Int,

View File

@ -9,6 +9,7 @@ class ProxySettingsStore(context: Context) {
return ProxyConfig( return ProxyConfig(
host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(), host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(),
portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(), portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(),
secretText = preferences.getString(KEY_SECRET, ProxyConfig.DEFAULT_SECRET).orEmpty(),
dcIpText = preferences.getString( dcIpText = preferences.getString(
KEY_DC_IP_TEXT, KEY_DC_IP_TEXT,
ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"), ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"),
@ -36,6 +37,7 @@ class ProxySettingsStore(context: Context) {
preferences.edit() preferences.edit()
.putString(KEY_HOST, config.host) .putString(KEY_HOST, config.host)
.putInt(KEY_PORT, config.port) .putInt(KEY_PORT, config.port)
.putString(KEY_SECRET, config.secret)
.putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n")) .putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n"))
.putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat()) .putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat())
.putInt(KEY_BUFFER_KB, config.bufferKb) .putInt(KEY_BUFFER_KB, config.bufferKb)
@ -49,6 +51,7 @@ class ProxySettingsStore(context: Context) {
private const val PREFS_NAME = "proxy_settings" private const val PREFS_NAME = "proxy_settings"
private const val KEY_HOST = "host" private const val KEY_HOST = "host"
private const val KEY_PORT = "port" private const val KEY_PORT = "port"
private const val KEY_SECRET = "secret"
private const val KEY_DC_IP_TEXT = "dc_ip_text" private const val KEY_DC_IP_TEXT = "dc_ip_text"
private const val KEY_LOG_MAX_MB = "log_max_mb" private const val KEY_LOG_MAX_MB = "log_max_mb"
private const val KEY_BUFFER_KB = "buf_kb" private const val KEY_BUFFER_KB = "buf_kb"

View File

@ -17,6 +17,7 @@ object PythonProxyBridge {
File(context.filesDir, "tg-ws-proxy").absolutePath, File(context.filesDir, "tg-ws-proxy").absolutePath,
config.host, config.host,
config.port, config.port,
config.secret,
config.dcIpList, config.dcIpList,
config.logMaxMb, config.logMaxMb,
config.bufferKb, config.bufferKb,

View File

@ -8,7 +8,7 @@ import android.net.Uri
object TelegramProxyIntent { object TelegramProxyIntent {
fun open(context: Context, config: NormalizedProxyConfig): Boolean { fun open(context: Context, config: NormalizedProxyConfig): Boolean {
val uri = Uri.parse( val uri = Uri.parse(
"tg://socks?server=${Uri.encode(config.host)}&port=${config.port}" "tg://proxy?server=${Uri.encode(config.host)}&port=${config.port}&secret=dd${Uri.encode(config.secret)}"
) )
val intent = Intent(Intent.ACTION_VIEW, uri) val intent = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

View File

@ -10,7 +10,7 @@ from proxy import __version__
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
RELEASES_PAGE_URL = "https://github.com/Dark-Avery/tg-ws-proxy/releases/latest" RELEASES_PAGE_URL = "https://github.com/Flowseal/tg-ws-proxy/releases/latest"
_RUNTIME_LOCK = threading.RLock() _RUNTIME_LOCK = threading.RLock()
@ -44,7 +44,7 @@ def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]:
return [str(item).strip() for item in values if str(item).strip()] return [str(item).strip() for item in values if str(item).strip()]
def start_proxy(app_dir: str, host: str, port: int, def start_proxy(app_dir: str, host: str, port: int, secret: str,
dc_ip_list: Iterable[object], log_max_mb: float = 5.0, dc_ip_list: Iterable[object], log_max_mb: float = 5.0,
buf_kb: int = 256, pool_size: int = 4, buf_kb: int = 256, pool_size: int = 4,
verbose: bool = False) -> str: verbose: bool = False) -> str:
@ -70,6 +70,7 @@ def start_proxy(app_dir: str, host: str, port: int,
config = { config = {
"host": host, "host": host,
"port": int(port), "port": int(port),
"secret": str(secret).strip(),
"dc_ip": _normalize_dc_ip_list(dc_ip_list), "dc_ip": _normalize_dc_ip_list(dc_ip_list),
"log_max_mb": float(log_max_mb), "log_max_mb": float(log_max_mb),
"buf_kb": int(buf_kb), "buf_kb": int(buf_kb),

View File

@ -243,6 +243,20 @@
android:maxLines="1" /> android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/secret_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/secretInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">TG WS Proxy</string> <string name="app_name">TG WS Proxy</string>
<string name="subtitle">Android app for the local Telegram SOCKS5 proxy.</string> <string name="subtitle">Android app for the local Telegram MTProto proxy.</string>
<string name="status_label">Foreground service</string> <string name="status_label">Foreground service</string>
<string name="status_starting">Starting</string> <string name="status_starting">Starting</string>
<string name="status_running">Running</string> <string name="status_running">Running</string>
@ -33,6 +33,7 @@
<string name="updates_open_release_button">Open Release Page</string> <string name="updates_open_release_button">Open Release Page</string>
<string name="host_hint">Proxy host</string> <string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string> <string name="port_hint">Proxy port</string>
<string name="secret_hint">MTProto secret (32 hex characters)</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string> <string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
<string name="log_max_mb_hint">Max log size before rotation (MB)</string> <string name="log_max_mb_hint">Max log size before rotation (MB)</string>
<string name="buffer_kb_hint">Socket buffer size (KB)</string> <string name="buffer_kb_hint">Socket buffer size (KB)</string>
@ -51,12 +52,12 @@
<string name="settings_saved">Settings saved</string> <string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string> <string name="service_start_requested">Foreground service start requested</string>
<string name="service_restart_requested">Foreground service restart requested</string> <string name="service_restart_requested">Foreground service restart requested</string>
<string name="telegram_not_found">Telegram app was not found for tg://socks.</string> <string name="telegram_not_found">Telegram app was not found for tg://proxy.</string>
<string name="notification_title">TG WS Proxy</string> <string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string> <string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string> <string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string>
<string name="notification_starting">SOCKS5 %1$s:%2$d • starting embedded Python</string> <string name="notification_starting">MTProto %1$s:%2$d • starting embedded Python</string>
<string name="notification_running">SOCKS5 %1$s:%2$d • proxy active</string> <string name="notification_running">MTProto %1$s:%2$d • proxy active</string>
<string name="notification_endpoint">%1$s:%2$d</string> <string name="notification_endpoint">%1$s:%2$d</string>
<string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string> <string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
<string name="notification_action_stop">Stop</string> <string name="notification_action_stop">Stop</string>

View File

@ -3,6 +3,7 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_BUILD_DIR="$ROOT_DIR/app/build"
if [[ -z "${GRADLE_USER_HOME:-}" ]]; then if [[ -z "${GRADLE_USER_HOME:-}" ]]; then
if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then
@ -40,6 +41,7 @@ GRADLE_BIN="gradle"
if [[ -x "$ROOT_DIR/gradlew" ]]; then if [[ -x "$ROOT_DIR/gradlew" ]]; then
GRADLE_BIN="$ROOT_DIR/gradlew" GRADLE_BIN="$ROOT_DIR/gradlew"
fi fi
GRADLE_RUN_DIR="$ROOT_DIR"
ATTEMPTS="${ATTEMPTS:-5}" ATTEMPTS="${ATTEMPTS:-5}"
SLEEP_SECONDS="${SLEEP_SECONDS:-15}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
@ -47,6 +49,34 @@ TASK="${1:-assembleStandardDebug}"
LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}" LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}"
CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}" CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}"
running_on_wsl_windows_mount() {
[[ -n "${WSL_DISTRO_NAME:-}" && "$ROOT_DIR" == /mnt/* ]]
}
prepare_wsl_build_dir() {
if ! running_on_wsl_windows_mount; then
return 0
fi
local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/tg-ws-proxy-android-build"
local project_key
project_key="$(printf '%s' "$ROOT_DIR" | sha256sum | cut -d' ' -f1)"
local linux_build_dir="$cache_root/$project_key/app-build"
mkdir -p "$cache_root"
mkdir -p "$linux_build_dir"
if [[ -L "$APP_BUILD_DIR" ]]; then
return 0
fi
if [[ -e "$APP_BUILD_DIR" ]]; then
rm -rf "$APP_BUILD_DIR"
fi
ln -s "$linux_build_dir" "$APP_BUILD_DIR"
}
task_uses_legacy32() { task_uses_legacy32() {
[[ "$TASK" =~ [Ll]egacy32 ]] [[ "$TASK" =~ [Ll]egacy32 ]]
} }
@ -123,11 +153,11 @@ prefetch_chaquopy_runtime() {
cleanup_stale_build_state() { cleanup_stale_build_state() {
local stale_dirs=( local stale_dirs=(
"$ROOT_DIR/app/build/python/env" "$APP_BUILD_DIR/python/env"
"$ROOT_DIR/app/build/intermediates/project_dex_archive" "$APP_BUILD_DIR/intermediates/project_dex_archive"
"$ROOT_DIR/app/build/intermediates/desugar_graph" "$APP_BUILD_DIR/intermediates/desugar_graph"
"$ROOT_DIR/app/build/tmp/kotlin-classes" "$APP_BUILD_DIR/tmp/kotlin-classes"
"$ROOT_DIR/app/build/snapshot/kotlin" "$APP_BUILD_DIR/snapshot/kotlin"
) )
for stale_dir in "${stale_dirs[@]}"; do for stale_dir in "${stale_dirs[@]}"; do
@ -138,10 +168,14 @@ cleanup_stale_build_state() {
} }
prefetch_chaquopy_runtime prefetch_chaquopy_runtime
prepare_wsl_build_dir
for attempt in $(seq 1 "$ATTEMPTS"); do for attempt in $(seq 1 "$ATTEMPTS"); do
echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)"
if "$GRADLE_BIN" --no-daemon --console=plain "$TASK"; then if (
cd "$GRADLE_RUN_DIR"
"$GRADLE_BIN" --no-daemon --console=plain "$TASK"
); then
exit 0 exit 0
fi fi

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 473 B

607
linux.py
View File

@ -1,241 +1,44 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
import logging.handlers
import os import os
import subprocess import subprocess
import sys import sys
import threading import threading
import webbrowser
import time import time
from pathlib import Path
from typing import Optional from typing import Optional
import customtkinter as ctk import customtkinter as ctk
import psutil
import pyperclip import pyperclip
import pystray import pystray
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageTk
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.app_runtime import ProxyAppRuntime
from proxy import __version__ from utils.tray_common import (
from utils.default_config import default_tray_config APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_buttons, install_tray_config_form,
install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer,
populate_first_run_window,
tray_settings_scroll_and_footer,
validate_config_form, validate_config_form,
) )
from ui.ctk_theme import ( from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
CONFIG_DIALOG_SIZE, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
FIRST_RUN_SIZE,
create_ctk_root,
ctk_theme_for_platform,
main_content_frame,
) )
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = default_tray_config()
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") # dialogs (tkinter messagebox)
_runtime = ProxyAppRuntime(
APP_DIR,
default_config=DEFAULT_CONFIG,
logger_name="tg-ws-tray",
on_error=lambda text: _show_error(text),
)
CONFIG_FILE = _runtime.config_file
LOG_FILE = _runtime.log_file
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: def _msgbox(kind: str, text: str, title: str, **kw):
try:
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
cmdline = proc.cmdline()
for arg in cmdline:
if "linux.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock():
global _lock_file_path
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool:
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs():
_runtime.ensure_dirs()
def load_config() -> dict:
return _runtime.load_config()
def save_config(cfg: dict):
_runtime.save_config(cfg)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_runtime.setup_logging(verbose, log_max_mb=log_max_mb)
def _make_icon_image(size: int = 64):
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse(
[margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255)
)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
size=int(size * 0.55),
)
except Exception:
try:
font = ImageFont.truetype(
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", size=int(size * 0.55)
)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def start_proxy():
_runtime.start_proxy(_config)
def stop_proxy():
_runtime.stop_proxy()
def restart_proxy():
_runtime.restart_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showerror(title, text, parent=root)
root.destroy()
def _show_info(text: str, title: str = "TG WS Proxy"):
import tkinter as _tk
from tkinter import messagebox as _mb
root = _tk.Tk()
root.withdraw()
_mb.showinfo(title, text, parent=root)
root.destroy()
def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
import tkinter as _tk import tkinter as _tk
from tkinter import messagebox as _mb from tkinter import messagebox as _mb
@ -245,273 +48,189 @@ def _ask_yes_no_dialog(text: str, title: str = "TG WS Proxy") -> bool:
root.attributes("-topmost", True) root.attributes("-topmost", True)
except Exception: except Exception:
pass pass
r = _mb.askyesno(title, text, parent=root) result = getattr(_mb, kind)(title, text, parent=root, **kw)
root.destroy() root.destroy()
return bool(r) return result
def _maybe_notify_update_async(): def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
def _work(): _msgbox("showerror", text, title)
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
text = (
f"Доступна новая версия: {ver}\n\n"
f"Открыть страницу релиза в браузере?"
)
if _ask_yes_no_dialog(text, "TG WS Proxy — обновление"):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _on_open_in_telegram(icon=None, item=None): def _show_info(text: str, title: str = "TG WS Proxy") -> None:
host = _config.get("host", DEFAULT_CONFIG["host"]) _msgbox("showinfo", text, title)
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return bool(_msgbox("askyesno", text, title))
def _apply_window_icon(root) -> None:
icon_img = load_icon()
if icon_img:
root._ctk_icon_photo = ImageTk.PhotoImage(icon_img.resize((64, 64)))
root.iconphoto(False, root._ctk_icon_photo)
# tray callbacks
def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
log.info("Copying %s", url) log.info("Copying %s", url)
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
"TG WS Proxy",
) )
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(icon=None, item=None): def _on_copy_link(icon=None, item=None) -> None:
threading.Thread(target=restart_proxy, daemon=True).start() url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
try:
pyperclip.copy(url)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_edit_config(icon=None, item=None): def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error), daemon=True
).start()
def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start() threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog(): def _on_open_logs(icon=None, item=None) -> None:
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_root(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False,
)
def on_save():
merged = validate_config_form(
widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
_show_error(merged)
return
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
_tray_icon.menu = _build_menu()
from tkinter import messagebox
if messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE) log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists(): if LOG_FILE.exists():
env = os.environ.copy() env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME")}
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
subprocess.Popen( subprocess.Popen(
["xdg-open", str(LOG_FILE)], ["xdg-open", str(LOG_FILE)], env=env,
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
) )
else: else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy") _show_info("Файл логов ещё не создан.")
def _on_exit(icon=None, item=None): def _on_exit(icon=None, item=None) -> None:
global _exiting global _exiting
if _exiting: if _exiting:
os._exit(0) os._exit(0)
return return
_exiting = True _exiting = True
log.info("User requested exit") log.info("User requested exit")
quit_ctk()
def _force_exit(): threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon: if icon:
icon.stop() icon.stop()
def _show_first_run(): # settings dialog
_ensure_dirs()
def _edit_config_dialog() -> None:
if not ensure_ctk_thread(ctk):
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
after_create=_apply_window_icon,
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False)
def _finish() -> None:
root.destroy()
done.set()
def on_save() -> None:
from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False)
if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
_tray_icon.menu = _build_menu()
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
ctk_run_dialog(_build)
# first run
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
if not ensure_ctk_thread(ctk):
FIRST_RUN_MARKER.touch()
return
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
if ctk is None: def _build(done: threading.Event) -> None:
FIRST_RUN_MARKER.touch() theme = ctk_theme_for_platform()
return w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
after_create=_apply_window_icon,
)
theme = ctk_theme_for_platform() def on_done(open_tg: bool) -> None:
w, h = FIRST_RUN_SIZE FIRST_RUN_MARKER.touch()
root.destroy()
done.set()
if open_tg:
_on_open_in_telegram()
root = create_ctk_root( populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
ctk,
title="TG WS Proxy",
width=w,
height=h,
theme=theme,
after_create=_apply_linux_ctk_window_icon,
)
def on_done(open_tg: bool): ctk_run_dialog(_build)
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
populate_first_run_window(
ctk, root, theme, host=host, port=port, on_done=on_done)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool: # tray menu
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy",
)
def _build_menu(): def _build_menu():
if pystray is None:
return None
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
f"Открыть в Telegram ({host}:{port})", _on_open_in_telegram, default=True pystray.MenuItem("Скопировать ссылку", _on_copy_link),
),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem("Настройки...", _on_edit_config),
@ -521,21 +240,18 @@ def _build_menu():
) )
def run_tray(): # entry point
def run_tray() -> None:
global _tray_icon, _config global _tray_icon, _config
_config = _runtime.prepare() _config = load_config()
_runtime.reset_log_file() bootstrap(_config)
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if pystray is None or Image is None: if pystray is None or Image is None:
log.error("pystray or Pillow not installed; running in console mode") log.error("pystray or Pillow not installed; running in console mode")
start_proxy() start_proxy(_config, _show_error)
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
@ -543,16 +259,12 @@ def run_tray():
stop_proxy() stop_proxy()
return return
start_proxy() start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() check_ipv6_warning(_show_info)
icon_image = _load_icon()
_tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu())
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@ -560,15 +272,14 @@ def run_tray():
log.info("Tray app exited") log.info("Tray app exited")
def main(): def main() -> None:
if not _acquire_lock(): if not acquire_lock("linux.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
finally: finally:
_release_lock() release_lock()
if __name__ == "__main__": if __name__ == "__main__":

582
macos.py
View File

@ -1,10 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import logging
import logging.handlers
import os import os
import psutil
import subprocess import subprocess
import sys import sys
import threading import threading
@ -29,241 +25,189 @@ except ImportError:
pyperclip = None pyperclip = None
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.app_runtime import ProxyAppRuntime
from proxy import __version__ from proxy import __version__
from utils.default_config import default_tray_config
APP_NAME = "TgWsProxy" from utils.tray_common import (
APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
CONFIG_FILE = APP_DIR / "config.json" LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
LOG_FILE = APP_DIR / "proxy.log" log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
FIRST_RUN_MARKER = APP_DIR / ".first_run_done" )
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
DEFAULT_CONFIG = default_tray_config() _proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[object] = None
_app: Optional[object] = None _app: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting: bool = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") # osascript dialogs
_runtime = ProxyAppRuntime(
APP_DIR,
default_config=DEFAULT_CONFIG,
logger_name="tg-ws-tray",
on_error=lambda text: _show_error(text),
)
CONFIG_FILE = _runtime.config_file
LOG_FILE = _runtime.log_file
# Single-instance lock def _esc(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool:
try: def _osascript(script: str) -> str:
lock_ct = float(lock_meta.get("create_time", 0.0)) r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
proc_ct = float(proc.create_time()) return r.stdout.strip()
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception: def _show_error(text: str, title: str = "TG WS Proxy") -> None:
_osascript(
f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
f'buttons {{"OK"}} default button "OK" with icon stop'
)
def _show_info(text: str, title: str = "TG WS Proxy") -> None:
_osascript(
f'display dialog "{_esc(text)}" with title "{_esc(title)}" '
f'buttons {{"OK"}} default button "OK" with icon note'
)
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
return _ask_yes_no_close(text, title) is True
def _ask_yes_no_close(text: str, title: str = "TG WS Proxy") -> Optional[bool]:
r = subprocess.run(
[
"osascript", "-e",
f'button returned of (display dialog "{_esc(text)}" '
f'with title "{_esc(title)}" '
f'buttons {{"Закрыть", "Нет", "Да"}} '
f'default button "Да" cancel button "Закрыть" with icon note)',
],
capture_output=True, text=True,
)
if r.returncode != 0:
return None
btn = r.stdout.strip()
if btn == "Да":
return True
if btn == "Нет":
return False return False
return None
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return APP_NAME.lower() in proc.name().lower()
return False
def _release_lock(): def _osascript_input(prompt: str, default: str, title: str = "TG WS Proxy") -> Optional[str]:
global _lock_file_path r = subprocess.run(
if not _lock_file_path: [
return "osascript", "-e",
try: f'text returned of (display dialog "{_esc(prompt)}" '
_lock_file_path.unlink(missing_ok=True) f'default answer "{_esc(default)}" '
except Exception: f'with title "{_esc(title)}" '
pass f'buttons {{"Закрыть", "OK"}} '
_lock_file_path = None f'default button "OK" cancel button "Закрыть")',
],
capture_output=True, text=True,
)
if r.returncode != 0:
return None
return r.stdout.rstrip("\r\n")
def _acquire_lock() -> bool: # menubar icon
global _lock_file_path
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {"create_time": proc.create_time()}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
# Filesystem helpers
def _ensure_dirs():
_runtime.ensure_dirs()
def load_config() -> dict:
return _runtime.load_config()
def save_config(cfg: dict):
_runtime.save_config(cfg)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_runtime.setup_logging(verbose, log_max_mb=log_max_mb)
# Menubar icon
def _make_menubar_icon(size: int = 44): def _make_menubar_icon(size: int = 44):
if Image is None: if Image is None:
return None return None
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
margin = size // 11 margin = size // 11
draw.ellipse([margin, margin, size - margin, size - margin], draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
fill=(0, 0, 0, 255))
try: try:
font = ImageFont.truetype( font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55))
"/System/Library/Fonts/Helvetica.ttc",
size=int(size * 0.55))
except Exception: except Exception:
font = ImageFont.load_default() font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font) bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0] draw.text(
ty = (size - th) // 2 - bbox[1] ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) "T", fill=(255, 255, 255, 255), font=font,
)
return img return img
# Generate menubar icon PNG if it does not exist.
def _ensure_menubar_icon(): def _ensure_menubar_icon() -> None:
if MENUBAR_ICON_PATH.exists(): if MENUBAR_ICON_PATH.exists():
return return
_ensure_dirs() ensure_dirs()
img = _make_menubar_icon(44) img = _make_menubar_icon(44)
if img: if img:
img.save(str(MENUBAR_ICON_PATH), "PNG") img.save(str(MENUBAR_ICON_PATH), "PNG")
# Native macOS dialogs # proxy lifecycle (macOS-local)
def _escape_osascript_text(text: str) -> str: import asyncio as _asyncio
return text.replace('\\', '\\\\').replace('"', '\\"')
def _osascript(script: str) -> str: def _run_proxy_thread() -> None:
r = subprocess.run( global _async_stop
['osascript', '-e', script], loop = _asyncio.new_event_loop()
capture_output=True, text=True) _asyncio.set_event_loop(loop)
return r.stdout.strip() stop_ev = _asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc):
_show_error(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def _show_error(text: str, title: str = "TG WS Proxy"): def _start_proxy() -> None:
text_esc = _escape_osascript_text(text) global _proxy_thread
title_esc = _escape_osascript_text(title) if _proxy_thread and _proxy_thread.is_alive():
_osascript( log.info("Proxy already running")
f'display dialog "{text_esc}" with title "{title_esc}" ' return
f'buttons {{"OK"}} default button "OK" with icon stop') if not apply_proxy_config(_config):
_show_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
_proxy_thread = threading.Thread(target=_run_proxy_thread, daemon=True, name="proxy")
_proxy_thread.start()
def _show_info(text: str, title: str = "TG WS Proxy"): def _stop_proxy() -> None:
text_esc = _escape_osascript_text(text) global _proxy_thread, _async_stop
title_esc = _escape_osascript_text(title) if _async_stop:
_osascript( loop, stop_ev = _async_stop
f'display dialog "{text_esc}" with title "{title_esc}" ' loop.call_soon_threadsafe(stop_ev.set)
f'buttons {{"OK"}} default button "OK" with icon note') if _proxy_thread:
_proxy_thread.join(timeout=2)
_proxy_thread = None
log.info("Proxy stopped")
def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: def _restart_proxy() -> None:
result = _ask_yes_no_close(text, title) log.info("Restarting proxy...")
return result is True _stop_proxy()
time.sleep(0.3)
_start_proxy()
def _ask_yes_no_close(text: str, # menu callbacks
title: str = "TG WS Proxy") -> Optional[bool]:
text_esc = _escape_osascript_text(text)
title_esc = _escape_osascript_text(title)
r = subprocess.run(
['osascript', '-e',
f'button returned of (display dialog "{text_esc}" '
f'with title "{title_esc}" '
f'buttons {{"Закрыть", "Нет", "Да"}} '
f'default button "Да" cancel button "Закрыть" with icon note)'],
capture_output=True, text=True)
if r.returncode != 0:
return None
result = r.stdout.strip()
if result == "Да":
return True
if result == "Нет":
return False
return None
# Proxy lifecycle def _on_open_in_telegram(_=None) -> None:
url = tg_proxy_url(_config)
def start_proxy():
_runtime.start_proxy(_config)
def stop_proxy():
_runtime.stop_proxy()
def restart_proxy():
_runtime.restart_proxy()
# Menu callbacks
def _on_open_in_telegram(_=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
log.info("Opening %s", url) log.info("Opening %s", url)
try: try:
result = subprocess.call(['open', url]) result = subprocess.call(["open", url])
if result != 0: if result != 0:
raise RuntimeError("open command failed") raise RuntimeError("open command failed")
except Exception: except Exception:
@ -277,67 +221,58 @@ def _on_open_in_telegram(_=None):
if pyperclip: if pyperclip:
pyperclip.copy(url) pyperclip.copy(url)
else: else:
subprocess.run(['pbcopy'], input=url.encode(), subprocess.run(["pbcopy"], input=url.encode(), check=True)
check=True)
_show_info( _show_info(
"Не удалось открыть Telegram автоматически.\n\n" "Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена:\n{url}") f"Ссылка скопирована в буфер обмена:\n{url}"
)
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(_=None): def _on_copy_link(_=None) -> None:
def _do_restart(): url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
try:
if pyperclip:
pyperclip.copy(url)
else:
subprocess.run(["pbcopy"], input=url.encode(), check=True)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(_=None) -> None:
def _do():
global _config global _config
_config = load_config() _config = load_config()
if _app: if _app:
_app.update_menu_title() _app.update_menu_title()
restart_proxy() _restart_proxy()
threading.Thread(target=_do_restart, daemon=True).start() threading.Thread(target=_do, daemon=True).start()
def _on_open_logs(_=None): def _on_open_logs(_=None) -> None:
log.info("Opening log file: %s", LOG_FILE) log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists(): if LOG_FILE.exists():
subprocess.call(['open', str(LOG_FILE)]) subprocess.call(["open", str(LOG_FILE)])
else: else:
_show_info("Файл логов ещё не создан.") _show_info("Файл логов ещё не создан.")
# Show a native text input dialog. Returns None if cancelled.
def _osascript_input(prompt: str, default: str,
title: str = "TG WS Proxy") -> Optional[str]:
prompt_esc = _escape_osascript_text(prompt)
default_esc = _escape_osascript_text(default)
title_esc = _escape_osascript_text(title)
r = subprocess.run(
['osascript', '-e',
f'text returned of (display dialog "{prompt_esc}" '
f'default answer "{default_esc}" '
f'with title "{title_esc}" '
f'buttons {{"Закрыть", "OK"}} '
f'default button "OK" cancel button "Закрыть")'],
capture_output=True, text=True)
if r.returncode != 0:
return None
return r.stdout.rstrip("\r\n")
def _on_edit_config(_=None) -> None:
def _on_edit_config(_=None):
threading.Thread(target=_edit_config_dialog, daemon=True).start() threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _check_updates_menu_title() -> str: def _check_updates_menu_title() -> str:
on = bool(_config.get("check_updates", True)) on = bool(_config.get("check_updates", True))
return ( return "✓ Проверять обновления при запуске" if on else "Проверять обновления при запуске (выкл)"
"✓ Проверять обновления при запуске"
if on
else "Проверять обновления при запуске (выкл)"
)
def _toggle_check_updates(_=None): def _toggle_check_updates(_=None) -> None:
global _config global _config
_config["check_updates"] = not bool(_config.get("check_updates", True)) _config["check_updates"] = not bool(_config.get("check_updates", True))
save_config(_config) save_config(_config)
@ -345,12 +280,15 @@ def _toggle_check_updates(_=None):
_app._check_updates_item.title = _check_updates_menu_title() _app._check_updates_item.title = _check_updates_menu_title()
def _on_open_release_page(_=None): def _on_open_release_page(_=None) -> None:
from utils.update_check import RELEASES_PAGE_URL from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL) webbrowser.open(RELEASES_PAGE_URL)
def _maybe_notify_update_async(): # update check
def _maybe_notify_update_async() -> None:
def _work(): def _work():
time.sleep(1.5) time.sleep(1.5)
if _exiting: if _exiting:
@ -366,8 +304,7 @@ def _maybe_notify_update_async():
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" ver = st.get("latest") or "?"
if _ask_yes_no( if _ask_yes_no(
f"Доступна новая версия: {ver}\n\n" f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
f"Открыть страницу релиза в браузере?",
"TG WS Proxy — обновление", "TG WS Proxy — обновление",
): ):
webbrowser.open(url) webbrowser.open(url)
@ -377,18 +314,16 @@ def _maybe_notify_update_async():
threading.Thread(target=_work, daemon=True, name="update-check").start() threading.Thread(target=_work, daemon=True, name="update-check").start()
# Settings via native macOS dialogs # settings dialog
def _edit_config_dialog():
def _edit_config_dialog() -> None:
cfg = load_config() cfg = load_config()
# Host host = _osascript_input("IP-адрес прокси:", cfg.get("host", DEFAULT_CONFIG["host"]))
host = _osascript_input(
"IP-адрес прокси:",
cfg.get("host", DEFAULT_CONFIG["host"]))
if host is None: if host is None:
return return
host = host.strip() host = host.strip()
import socket as _sock import socket as _sock
try: try:
_sock.inet_aton(host) _sock.inet_aton(host)
@ -396,10 +331,7 @@ def _edit_config_dialog():
_show_error("Некорректный IP-адрес.") _show_error("Некорректный IP-адрес.")
return return
# Port port_str = _osascript_input("Порт прокси:", str(cfg.get("port", DEFAULT_CONFIG["port"])))
port_str = _osascript_input(
"Порт прокси:",
str(cfg.get("port", DEFAULT_CONFIG["port"])))
if port_str is None: if port_str is None:
return return
try: try:
@ -410,42 +342,49 @@ def _edit_config_dialog():
_show_error("Порт должен быть числом 1-65535") _show_error("Порт должен быть числом 1-65535")
return return
# DC-IP mappings secret_str = _osascript_input(
"MTProto Secret (32 hex символа):", cfg.get("secret", DEFAULT_CONFIG["secret"])
)
if secret_str is None:
return
secret_str = secret_str.strip().lower()
if len(secret_str) != 32 or not all(c in "0123456789abcdef" for c in secret_str):
_show_error("Secret должен быть строкой из 32 шестнадцатеричных символов.")
return
dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))
dc_str = _osascript_input( dc_str = _osascript_input(
"DC → IP маппинги (через запятую, формат DC:IP):\n" "DC → IP маппинги (через запятую, формат DC:IP):\n"
"Например: 2:149.154.167.220, 4:149.154.167.220", "Например: 2:149.154.167.220, 4:149.154.167.220",
dc_default) dc_default,
)
if dc_str is None: if dc_str is None:
return return
dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() dc_lines = [s.strip() for s in dc_str.replace(",", "\n").splitlines() if s.strip()]
if s.strip()]
try: try:
tg_ws_proxy.parse_dc_ip_list(dc_lines) tg_ws_proxy.parse_dc_ip_list(dc_lines)
except ValueError as e: except ValueError as e:
_show_error(str(e)) _show_error(str(e))
return return
# Verbose
verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?") verbose = _ask_yes_no_close("Включить подробное логирование (verbose)?")
if verbose is None: if verbose is None:
return return
# Advanced settings
adv_str = _osascript_input( adv_str = _osascript_input(
"Расширенные настройки (буфер KB, WS пул, лог MB):\n" "Расширенные настройки (буфер KB, WS пул, лог MB):\n"
"Формат: buf_kb,pool_size,log_max_mb", "Формат: buf_kb,pool_size,log_max_mb",
f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])},"
f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])},"
f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}",
)
if adv_str is None: if adv_str is None:
return return
adv = {} adv = {}
if adv_str: if adv_str:
parts = [s.strip() for s in adv_str.split(',')] parts = [s.strip() for s in adv_str.split(",")]
keys = [("buf_kb", int), ("pool_size", int), keys = [("buf_kb", int), ("pool_size", int), ("log_max_mb", float)]
("log_max_mb", float)]
for i, (k, typ) in enumerate(keys): for i, (k, typ) in enumerate(keys):
if i < len(parts): if i < len(parts):
try: try:
@ -456,11 +395,13 @@ def _edit_config_dialog():
new_cfg = { new_cfg = {
"host": host, "host": host,
"port": port, "port": port,
"secret": secret_str,
"dc_ip": dc_lines, "dc_ip": dc_lines,
"verbose": verbose, "verbose": verbose,
"buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])),
"pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])),
"log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])),
"check_updates": cfg.get("check_updates", True),
} }
save_config(new_cfg) save_config(new_cfg)
log.info("Config saved: %s", new_cfg) log.info("Config saved: %s", new_cfg)
@ -470,21 +411,23 @@ def _edit_config_dialog():
if _app: if _app:
_app.update_menu_title() _app.update_menu_title()
if _ask_yes_no_close( if _ask_yes_no_close("Настройки сохранены.\n\nПерезапустить прокси сейчас?"):
"Настройки сохранены.\n\nПерезапустить прокси сейчас?"): _restart_proxy()
restart_proxy()
# First-run & IPv6 dialogs # first run & ipv6
def _show_first_run():
_ensure_dirs() def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
tg_url = f"tg://socks?server={host}&port={port}" secret = _config.get("secret", DEFAULT_CONFIG["secret"])
tg_url = tg_proxy_url(_config)
link_host = tg_ws_proxy.get_link_host(host)
text = ( text = (
f"Прокси запущен и работает в строке меню.\n\n" f"Прокси запущен и работает в строке меню.\n\n"
@ -494,54 +437,54 @@ def _show_first_run():
f" Или ссылка: {tg_url}\n\n" f" Или ссылка: {tg_url}\n\n"
f"Вручную:\n" f"Вручную:\n"
f" Настройки → Продвинутые → Тип подключения → Прокси\n" f" Настройки → Продвинутые → Тип подключения → Прокси\n"
f" SOCKS5 → {host} : {port} (без логина/пароля)\n\n" f" MTProto → {link_host} : {port} \n"
f" Secret: dd{secret} \n\n"
f"Открыть прокси в Telegram сейчас?" f"Открыть прокси в Telegram сейчас?"
) )
FIRST_RUN_MARKER.touch() FIRST_RUN_MARKER.touch()
if _ask_yes_no(text, "TG WS Proxy"): if _ask_yes_no(text, "TG WS Proxy"):
_on_open_in_telegram() _on_open_in_telegram()
def _has_ipv6_enabled() -> bool: def _check_ipv6_warning() -> None:
import socket as _sock ensure_dirs()
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'):
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists(): if IPV6_WARN_MARKER.exists():
return return
if not _has_ipv6_enabled():
import socket as _sock
has = False
try:
for addr in _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6):
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
has = True
break
except Exception:
pass
if not has:
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
has = True
except Exception:
pass
if not has:
return return
IPV6_WARN_MARKER.touch() IPV6_WARN_MARKER.touch()
_show_info( _show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n" "На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, " "Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n" "что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает, попробуйте отключить " "Если прокси не работает, попробуйте отключить "
"попытку соединения по IPv6 в настройках прокси Telegram.\n\n" "попытку соединения по IPv6 в настройках прокси Telegram.\n\n"
"Это предупреждение будет показано только один раз.") "Это предупреждение будет показано только один раз."
)
# rumps menubar app # rumps app
_TgWsProxyAppBase = rumps.App if rumps else object _TgWsProxyAppBase = rumps.App if rumps else object
@ -549,33 +492,26 @@ _TgWsProxyAppBase = rumps.App if rumps else object
class TgWsProxyApp(_TgWsProxyAppBase): class TgWsProxyApp(_TgWsProxyAppBase):
def __init__(self): def __init__(self):
_ensure_menubar_icon() _ensure_menubar_icon()
icon_path = (str(MENUBAR_ICON_PATH) icon_path = str(MENUBAR_ICON_PATH) if MENUBAR_ICON_PATH.exists() else None
if MENUBAR_ICON_PATH.exists() else None)
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
self._open_tg_item = rumps.MenuItem( self._open_tg_item = rumps.MenuItem(
f"Открыть в Telegram ({host}:{port})", f"Открыть в Telegram ({link_host}:{port})", callback=_on_open_in_telegram
callback=_on_open_in_telegram) )
self._restart_item = rumps.MenuItem( self._copy_link_item = rumps.MenuItem("Скопировать ссылку", callback=_on_copy_link)
"Перезапустить прокси", self._restart_item = rumps.MenuItem("Перезапустить прокси", callback=_on_restart)
callback=_on_restart) self._settings_item = rumps.MenuItem("Настройки...", callback=_on_edit_config)
self._settings_item = rumps.MenuItem( self._logs_item = rumps.MenuItem("Открыть логи", callback=_on_open_logs)
"Настройки...",
callback=_on_edit_config)
self._logs_item = rumps.MenuItem(
"Открыть логи",
callback=_on_open_logs)
self._release_page_item = rumps.MenuItem( self._release_page_item = rumps.MenuItem(
"Страница релиза на GitHub…", "Страница релиза на GitHub…", callback=_on_open_release_page
callback=_on_open_release_page) )
self._check_updates_item = rumps.MenuItem( self._check_updates_item = rumps.MenuItem(
_check_updates_menu_title(), _check_updates_menu_title(), callback=_toggle_check_updates
callback=_toggle_check_updates) )
self._version_item = rumps.MenuItem( self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None)
f"Версия {__version__}",
callback=lambda _: None)
super().__init__( super().__init__(
"TG WS Proxy", "TG WS Proxy",
@ -584,6 +520,7 @@ class TgWsProxyApp(_TgWsProxyAppBase):
quit_button="Выход", quit_button="Выход",
menu=[ menu=[
self._open_tg_item, self._open_tg_item,
self._copy_link_item,
None, None,
self._restart_item, self._restart_item,
self._settings_item, self._settings_item,
@ -593,41 +530,51 @@ class TgWsProxyApp(_TgWsProxyAppBase):
self._check_updates_item, self._check_updates_item,
None, None,
self._version_item, self._version_item,
]) ],
)
def update_menu_title(self): def update_menu_title(self) -> None:
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
self._open_tg_item.title = ( link_host = tg_ws_proxy.get_link_host(host)
f"Открыть в Telegram ({host}:{port})") self._open_tg_item.title = f"Открыть в Telegram ({link_host}:{port})"
def run_menubar(): # entry point
def run_menubar() -> None:
global _app, _config global _app, _config
_config = _runtime.prepare() _config = load_config()
_runtime.reset_log_file() save_config(_config)
setup_logging(_config.get("verbose", False), if LOG_FILE.exists():
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(
_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s, menubar app starting", __version__) log.info("TG WS Proxy версия %s, menubar app starting", __version__)
log.info("Config: %s", _config) log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE) log.info("Log file: %s", LOG_FILE)
if rumps is None or Image is None: if rumps is None or Image is None:
log.error("rumps or Pillow not installed; running in console mode") log.error("rumps or Pillow not installed; running in console mode")
start_proxy() _start_proxy()
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
stop_proxy() _stop_proxy()
return return
start_proxy() _start_proxy()
_maybe_notify_update_async() _maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() _check_ipv6_warning()
@ -635,19 +582,18 @@ def run_menubar():
log.info("Menubar app running") log.info("Menubar app running")
_app.run() _app.run()
stop_proxy() _stop_proxy()
log.info("Menubar app exited") log.info("Menubar app exited")
def main(): def main() -> None:
if not _acquire_lock(): if not acquire_lock("macos.py"):
_show_info("Приложение уже запущено.") _show_info("Приложение уже запущено.")
return return
try: try:
run_menubar() run_menubar()
finally: finally:
_release_lock() release_lock()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1 +1 @@
__version__ = "1.3.0" __version__ = "1.4.0"

View File

@ -4,6 +4,7 @@ import asyncio as _asyncio
import json import json
import logging import logging
import logging.handlers import logging.handlers
import os
import sys import sys
import threading import threading
import time import time
@ -14,8 +15,9 @@ import proxy.tg_ws_proxy as tg_ws_proxy
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"port": 1080, "port": 1443,
"host": "127.0.0.1", "host": "127.0.0.1",
"secret": os.urandom(16).hex(),
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"log_max_mb": 5, "log_max_mb": 5,
"buf_kb": 256, "buf_kb": 256,
@ -48,6 +50,27 @@ class ProxyAppRuntime:
self._proxy_thread = None self._proxy_thread = None
self._async_stop = None self._async_stop = None
def _build_core_config(self, active_cfg: dict, dc_opt: Dict[int, str]):
port = int(active_cfg.get("port", self.default_config["port"]))
host = str(active_cfg.get("host", self.default_config["host"]))
secret = str(active_cfg.get("secret") or "").strip()
if not secret:
secret = os.urandom(16).hex()
active_cfg["secret"] = secret
buf_kb = int(active_cfg.get("buf_kb", self.default_config["buf_kb"]))
pool_size = int(active_cfg.get(
"pool_size", self.default_config["pool_size"]))
return tg_ws_proxy.ProxyConfig(
port=port,
host=host,
secret=secret,
dc_redirects=dc_opt,
buffer_size=max(4, buf_kb) * 1024,
pool_size=max(0, pool_size),
)
def ensure_dirs(self): def ensure_dirs(self):
self.app_dir.mkdir(parents=True, exist_ok=True) self.app_dir.mkdir(parents=True, exist_ok=True)
@ -132,17 +155,19 @@ class ProxyAppRuntime:
self._async_stop = (loop, stop_ev) self._async_stop = (loop, stop_ev)
try: try:
loop.run_until_complete( loop.run_until_complete(self.run_proxy(stop_event=stop_ev))
self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host))
except Exception as exc: except Exception as exc:
self.log.error("Proxy thread crashed: %s", exc) self.log.error("Proxy thread crashed: %s", exc)
if ("10048" in str(exc) or exc_text = str(exc)
"Address already in use" in str(exc)): if ("10048" in exc_text or
"address already in use" in exc_text.lower()):
self._emit_error( self._emit_error(
"Не удалось запустить прокси:\n" "Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n" "Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, " "Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите.") "или измените порт в настройках прокси и перезапустите.")
else:
self._emit_error(str(exc) or exc.__class__.__name__)
finally: finally:
loop.close() loop.close()
self._async_stop = None self._async_stop = None
@ -168,6 +193,9 @@ class ProxyAppRuntime:
self._emit_error("Ошибка конфигурации:\n%s" % exc) self._emit_error("Ошибка конфигурации:\n%s" % exc)
return False return False
tg_ws_proxy.proxy_config = self._build_core_config(active_cfg, dc_opt)
self.save_config(active_cfg)
self.log.info("Starting proxy on %s:%d ...", host, port) self.log.info("Starting proxy on %s:%d ...", host, port)
tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024
tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ keywords = [
"proxy", "proxy",
"bypass", "bypass",
"websocket", "websocket",
"socks5", "mtproto",
] ]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",

View File

@ -1,6 +1,7 @@
import sys import sys
import unittest import unittest
import json import json
import subprocess
from pathlib import Path from pathlib import Path
@ -107,7 +108,8 @@ class AndroidProxyBridgeTests(unittest.TestCase):
log_path = android_proxy_bridge.start_proxy( log_path = android_proxy_bridge.start_proxy(
"/tmp/app", "/tmp/app",
"127.0.0.1", "127.0.0.1",
1080, 1443,
"0123456789abcdef0123456789abcdef",
["2:149.154.167.220"], ["2:149.154.167.220"],
7.0, 7.0,
512, 512,
@ -118,6 +120,7 @@ class AndroidProxyBridgeTests(unittest.TestCase):
android_proxy_bridge.ProxyAppRuntime = original_runtime android_proxy_bridge.ProxyAppRuntime = original_runtime
self.assertEqual(log_path, "/tmp/proxy.log") self.assertEqual(log_path, "/tmp/proxy.log")
self.assertEqual(captured["config"]["secret"], "0123456789abcdef0123456789abcdef")
self.assertEqual(captured["config"]["log_max_mb"], 7.0) self.assertEqual(captured["config"]["log_max_mb"], 7.0)
self.assertEqual(captured["config"]["buf_kb"], 512) self.assertEqual(captured["config"]["buf_kb"], 512)
self.assertEqual(captured["config"]["pool_size"], 6) self.assertEqual(captured["config"]["pool_size"], 6)
@ -224,6 +227,38 @@ class AndroidProxyBridgeTests(unittest.TestCase):
self.assertEqual(result["error"], "") self.assertEqual(result["error"], "")
self.assertEqual(result["html_url"], "https://example.com/releases/latest") self.assertEqual(result["html_url"], "https://example.com/releases/latest")
def test_android_bridge_import_and_update_status_work_without_cryptography(self):
root = Path(__file__).resolve().parents[1]
script = f"""
import importlib.abc
import sys
from pathlib import Path
root = Path({str(root)!r})
sys.path.insert(0, str(root / "android" / "app" / "src" / "main" / "python"))
sys.path.insert(0, str(root))
class BlockCryptography(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname == "cryptography" or fullname.startswith("cryptography."):
raise ModuleNotFoundError("No module named 'cryptography'")
return None
sys.meta_path.insert(0, BlockCryptography())
import android_proxy_bridge
print(android_proxy_bridge.get_update_status_json(False))
"""
result = subprocess.run(
[sys.executable, "-c", script],
check=True,
capture_output=True,
text=True,
)
payload = json.loads(result.stdout.strip())
self.assertEqual(payload["current_version"], android_proxy_bridge.__version__)
self.assertEqual(payload["error"], "")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -116,6 +116,51 @@ class ProxyAppRuntimeTests(unittest.TestCase):
self.assertFalse(started) self.assertFalse(started)
self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"]) self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"])
def test_run_proxy_thread_reports_generic_runtime_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
async def fake_run_proxy(stop_event=None):
raise RuntimeError("proxy boom")
runtime = ProxyAppRuntime(
Path(tmpdir),
on_error=errors.append,
run_proxy=fake_run_proxy,
)
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
self.assertEqual(errors, ["proxy boom"])
def test_run_proxy_thread_reports_port_in_use_case_insensitively(self):
with tempfile.TemporaryDirectory() as tmpdir:
errors = []
async def fake_run_proxy(stop_event=None):
raise RuntimeError(
"[Errno 98] error while attempting to bind on address "
"('127.0.0.1', 1443): address already in use"
)
runtime = ProxyAppRuntime(
Path(tmpdir),
on_error=errors.append,
run_proxy=fake_run_proxy,
)
runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1")
self.assertEqual(
errors,
[
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -1,38 +1,55 @@
import hashlib
import struct import struct
import unittest import unittest
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from proxy.crypto_backend import create_aes_ctr_transform from proxy.crypto_backend import create_aes_ctr_transform
from proxy.tg_ws_proxy import _MsgSplitter, _dc_from_init, _patch_init_dc from proxy.tg_ws_proxy import (
PROTO_ABRIDGED_INT,
PROTO_TAG_ABRIDGED,
_MsgSplitter,
_generate_relay_init,
_try_handshake,
)
KEY = bytes(range(32)) KEY = bytes(range(32))
IV = bytes(range(16)) IV = bytes(range(16))
PROTO_TAG = 0xEFEFEFEF SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
def _xor(left: bytes, right: bytes) -> bytes: def _xor(left: bytes, right: bytes) -> bytes:
return bytes(a ^ b for a, b in zip(left, right)) return bytes(a ^ b for a, b in zip(left, right))
def _keystream(size: int) -> bytes: def _keystream(size: int, key: bytes, iv: bytes) -> bytes:
transform = create_aes_ctr_transform(KEY, IV) transform = Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor()
return transform.update(b"\x00" * size) + transform.finalize() return transform.update(b"\x00" * size)
def _build_init_packet(dc_raw: int, proto: int = PROTO_TAG) -> bytes: def _build_client_handshake(
dc_raw: int,
proto_tag: bytes = PROTO_TAG_ABRIDGED,
secret: bytes = SECRET,
) -> bytes:
packet = bytearray(64) packet = bytearray(64)
packet[8:40] = KEY packet[8:40] = KEY
packet[40:56] = IV packet[40:56] = IV
plain_tail = struct.pack("<Ih", proto, dc_raw) + b"\x00\x00" dec_key = hashlib.sha256(KEY + secret).digest()
packet[56:64] = _xor(plain_tail, _keystream(64)[56:64]) plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
packet[56:64] = _xor(plain_tail, _keystream(64, dec_key, IV)[56:64])
return bytes(packet) return bytes(packet)
def _encrypt_after_init(init_packet: bytes, plaintext: bytes) -> bytes: def _encrypt_after_init(relay_init: bytes, plaintext: bytes) -> bytes:
transform = create_aes_ctr_transform(init_packet[8:40], init_packet[40:56]) transform = Cipher(
algorithms.AES(relay_init[8:40]),
modes.CTR(relay_init[40:56]),
).encryptor()
transform.update(b"\x00" * 64) transform.update(b"\x00" * 64)
return transform.update(plaintext) + transform.finalize() return transform.update(plaintext)
class CryptoBackendTests(unittest.TestCase): class CryptoBackendTests(unittest.TestCase):
@ -63,42 +80,60 @@ class CryptoBackendTests(unittest.TestCase):
create_aes_ctr_transform(KEY, IV, backend="missing") create_aes_ctr_transform(KEY, IV, backend="missing")
class MtProtoInitTests(unittest.TestCase): class MtProtoHandshakeTests(unittest.TestCase):
def test_dc_from_init_reads_non_media_dc(self): def test_try_handshake_reads_non_media_dc(self):
init_packet = _build_init_packet(dc_raw=2) handshake = _build_client_handshake(dc_raw=2)
self.assertEqual(_dc_from_init(init_packet), (2, False)) result = _try_handshake(handshake, SECRET)
def test_dc_from_init_reads_media_dc(self): self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
init_packet = _build_init_packet(dc_raw=-4)
self.assertEqual(_dc_from_init(init_packet), (4, True)) def test_try_handshake_reads_media_dc(self):
handshake = _build_client_handshake(dc_raw=-4)
def test_patch_init_dc_updates_signed_dc_and_preserves_tail(self): result = _try_handshake(handshake, SECRET)
original = _build_init_packet(dc_raw=99) + b"tail"
patched = _patch_init_dc(original, -3) self.assertEqual(result[:3], (4, True, PROTO_TAG_ABRIDGED))
self.assertEqual(_dc_from_init(patched[:64]), (3, True)) def test_try_handshake_rejects_wrong_secret(self):
self.assertEqual(patched[64:], b"tail") handshake = _build_client_handshake(dc_raw=2)
result = _try_handshake(
handshake,
bytes.fromhex("fedcba9876543210fedcba9876543210"),
)
self.assertIsNone(result)
def test_generate_relay_init_encodes_proto_and_signed_dc(self):
relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -3)
decryptor = Cipher(
algorithms.AES(relay_init[8:40]),
modes.CTR(relay_init[40:56]),
).encryptor()
decrypted = decryptor.update(relay_init)
self.assertEqual(decrypted[56:60], PROTO_TAG_ABRIDGED)
self.assertEqual(struct.unpack("<h", decrypted[60:62])[0], -3)
class MsgSplitterTests(unittest.TestCase): class MsgSplitterTests(unittest.TestCase):
def test_splitter_splits_multiple_abridged_messages(self): def test_splitter_splits_multiple_abridged_messages(self):
init_packet = _build_init_packet(dc_raw=-2) relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
plain_chunk = b"\x01abcd\x02EFGH1234" plain_chunk = b"\x01abcd\x02EFGH1234"
encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
parts = _MsgSplitter(init_packet).split(encrypted_chunk) parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]]) self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]])
def test_splitter_leaves_single_message_intact(self): def test_splitter_leaves_single_message_intact(self):
init_packet = _build_init_packet(dc_raw=2) relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, 2)
plain_chunk = b"\x02abcdefgh" plain_chunk = b"\x02abcdefgh"
encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) encrypted_chunk = _encrypt_after_init(relay_init, plain_chunk)
parts = _MsgSplitter(init_packet).split(encrypted_chunk) parts = _MsgSplitter(relay_init, PROTO_ABRIDGED_INT).split(encrypted_chunk)
self.assertEqual(parts, [encrypted_chunk]) self.assertEqual(parts, [encrypted_chunk])

View File

@ -1,128 +1,61 @@
import asyncio import hashlib
import socket import struct
import unittest import unittest
from unittest.mock import patch from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from proxy.tg_ws_proxy import _handle_client, _socks5_reply from proxy.tg_ws_proxy import (
PROTO_TAG_ABRIDGED,
PROTO_TAG_INTERMEDIATE,
_generate_relay_init,
_try_handshake,
)
class _FakeTransport: KEY = bytes(range(32))
def get_extra_info(self, name): IV = bytes(range(16))
return None SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef")
def get_write_buffer_size(self):
return 0
class _FakeReader: def _xor(left: bytes, right: bytes) -> bytes:
def __init__(self, payload: bytes): return bytes(a ^ b for a, b in zip(left, right))
self._payload = payload
self._offset = 0
async def readexactly(self, n: int) -> bytes:
end = self._offset + n
if end > len(self._payload):
partial = self._payload[self._offset:]
self._offset = len(self._payload)
raise asyncio.IncompleteReadError(partial, n)
chunk = self._payload[self._offset:end]
self._offset = end
return chunk
class _FakeWriter: def _build_client_handshake(dc_raw: int, proto_tag: bytes) -> bytes:
def __init__(self): packet = bytearray(64)
self.transport = _FakeTransport() packet[8:40] = KEY
self.writes = [] packet[40:56] = IV
self.closed = False
self.close_calls = 0
def get_extra_info(self, name): dec_key = hashlib.sha256(KEY + SECRET).digest()
if name == "peername": decryptor = Cipher(algorithms.AES(dec_key), modes.CTR(IV)).encryptor()
return ("127.0.0.1", 50000) keystream = decryptor.update(b"\x00" * 64)
return None
def write(self, data: bytes): plain_tail = proto_tag + struct.pack("<h", dc_raw) + b"\x00\x00"
self.writes.append(data) packet[56:64] = _xor(plain_tail, keystream[56:64])
return bytes(packet)
async def drain(self):
return None
def close(self):
self.closed = True
self.close_calls += 1
async def wait_closed(self):
return None
def _ipv4_connect_request(ip: str, port: int, cmd: int = 1) -> bytes: class MtProtoProtocolTests(unittest.TestCase):
return bytes([0x05, cmd, 0x00, 0x01]) + socket.inet_aton(ip) + port.to_bytes(2, "big") def test_try_handshake_accepts_abridged_proto(self):
handshake = _build_client_handshake(2, PROTO_TAG_ABRIDGED)
result = _try_handshake(handshake, SECRET)
def _domain_connect_request(domain: str, port: int, cmd: int = 1) -> bytes: self.assertIsNotNone(result)
encoded = domain.encode("utf-8") self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED))
return (
bytes([0x05, cmd, 0x00, 0x03, len(encoded)])
+ encoded
+ port.to_bytes(2, "big")
)
def test_try_handshake_accepts_intermediate_proto(self):
handshake = _build_client_handshake(-4, PROTO_TAG_INTERMEDIATE)
def _ipv6_connect_request(ip: str, port: int) -> bytes: result = _try_handshake(handshake, SECRET)
return (
bytes([0x05, 0x01, 0x00, 0x04])
+ socket.inet_pton(socket.AF_INET6, ip)
+ port.to_bytes(2, "big")
)
self.assertIsNotNone(result)
self.assertEqual(result[:3], (4, True, PROTO_TAG_INTERMEDIATE))
class Socks5ProtocolTests(unittest.IsolatedAsyncioTestCase): def test_generate_relay_init_produces_handshake_sized_packet(self):
async def test_rejects_non_socks5_greeting(self): relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2)
reader = _FakeReader(b"\x04\x01")
writer = _FakeWriter()
await _handle_client(reader, writer) self.assertEqual(len(relay_init), 64)
self.assertEqual(relay_init[0], relay_init[0] & 0xFF)
self.assertEqual(writer.writes, [])
self.assertTrue(writer.closed)
async def test_rejects_unsupported_command(self):
reader = _FakeReader(b"\x05\x01\x00" + _ipv4_connect_request("1.1.1.1", 443, cmd=2))
writer = _FakeWriter()
await _handle_client(reader, writer)
self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x07)])
self.assertTrue(writer.closed)
async def test_rejects_unsupported_address_type(self):
reader = _FakeReader(b"\x05\x01\x00" + b"\x05\x01\x00\x02")
writer = _FakeWriter()
await _handle_client(reader, writer)
self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x08)])
self.assertTrue(writer.closed)
async def test_rejects_ipv6_destinations(self):
reader = _FakeReader(b"\x05\x01\x00" + _ipv6_connect_request("2001:db8::1", 443))
writer = _FakeWriter()
await _handle_client(reader, writer)
self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)])
self.assertTrue(writer.closed)
async def test_passthrough_connect_failure_returns_error(self):
reader = _FakeReader(b"\x05\x01\x00" + _domain_connect_request("example.com", 443))
writer = _FakeWriter()
with patch("proxy.tg_ws_proxy.asyncio.open_connection", side_effect=OSError("boom")):
await _handle_client(reader, writer)
self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)])
self.assertTrue(writer.closed)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,5 +1,6 @@
import unittest import unittest
from proxy import __version__
from utils import update_check from utils import update_check
@ -12,41 +13,44 @@ class UpdateCheckTests(unittest.TestCase):
update_check._state.update(self._orig_state) update_check._state.update(self._orig_state)
def test_apply_release_tag_marks_update_available(self): def test_apply_release_tag_marks_update_available(self):
version_parts = [int(part) for part in __version__.split(".")]
version_parts[-1] += 1
next_version = ".".join(str(part) for part in version_parts)
update_check._apply_release_tag( update_check._apply_release_tag(
tag="v1.3.1", tag=f"v{next_version}",
html_url="https://example.com/release", html_url="https://example.com/release",
current_version="1.3.0", current_version=__version__,
) )
status = update_check.get_status() status = update_check.get_status()
self.assertTrue(status["has_update"]) self.assertTrue(status["has_update"])
self.assertFalse(status["ahead_of_release"]) self.assertFalse(status["ahead_of_release"])
self.assertEqual(status["latest"], "1.3.1") self.assertEqual(status["latest"], next_version)
self.assertEqual(status["html_url"], "https://example.com/release") self.assertEqual(status["html_url"], "https://example.com/release")
def test_apply_release_tag_marks_ahead_of_release(self): def test_apply_release_tag_marks_ahead_of_release(self):
update_check._apply_release_tag( update_check._apply_release_tag(
tag="v1.1.2-relay", tag="v1.2.1",
html_url="https://example.com/release", html_url="https://example.com/release",
current_version="1.3.0", current_version=__version__,
) )
status = update_check.get_status() status = update_check.get_status()
self.assertFalse(status["has_update"]) self.assertFalse(status["has_update"])
self.assertTrue(status["ahead_of_release"]) self.assertTrue(status["ahead_of_release"])
self.assertEqual(status["latest"], "1.1.2-relay") self.assertEqual(status["latest"], "1.2.1")
def test_apply_release_tag_marks_latest_when_versions_match(self): def test_apply_release_tag_marks_latest_when_versions_match(self):
update_check._apply_release_tag( update_check._apply_release_tag(
tag="v1.3.0", tag=f"v{__version__}",
html_url="https://example.com/release", html_url="https://example.com/release",
current_version="1.3.0", current_version=__version__,
) )
status = update_check.get_status() status = update_check.get_status()
self.assertFalse(status["has_update"]) self.assertFalse(status["has_update"])
self.assertFalse(status["ahead_of_release"]) self.assertFalse(status["ahead_of_release"])
self.assertEqual(status["latest"], "1.3.0") self.assertEqual(status["latest"], __version__)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,8 +1,3 @@
"""
Общая светлая тема и фабрика окон CustomTkinter для tray-приложений (Windows / Linux).
Цвета и отступы задаются в одном месте правки темы не дублируются по платформам.
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
@ -13,11 +8,7 @@ from typing import Any, Callable, Optional, Tuple
_tk_variable_del_guard_installed = False _tk_variable_del_guard_installed = False
def _install_tkinter_variable_del_guard() -> None: def install_tkinter_variable_del_guard() -> None:
"""
Убирает «Exception ignored» при выходе процесса: Tcl уже разрушен, а GC ещё
вызывает Variable.__del__ (StringVar и т.д.) напр. окно CTk в фоновом потоке.
"""
global _tk_variable_del_guard_installed global _tk_variable_del_guard_installed
if _tk_variable_del_guard_installed: if _tk_variable_del_guard_installed:
return return
@ -32,24 +23,24 @@ def _install_tkinter_variable_del_guard() -> None:
tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment] tkinter.Variable.__del__ = _safe_variable_del # type: ignore[assignment]
_tk_variable_del_guard_installed = True _tk_variable_del_guard_installed = True
# Размеры и отступы (единые для диалогов настроек и первого запуска)
CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560) CONFIG_DIALOG_SIZE: Tuple[int, int] = (460, 560)
CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14) CONFIG_DIALOG_FRAME_PAD: Tuple[int, int] = (20, 14)
FIRST_RUN_SIZE: Tuple[int, int] = (520, 440) FIRST_RUN_SIZE: Tuple[int, int] = (520, 480)
FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24) FIRST_RUN_FRAME_PAD: Tuple[int, int] = (28, 24)
@dataclass(frozen=True) @dataclass(frozen=True)
class CtkTheme: class CtkTheme:
"""Палитра Telegram-style и семейства шрифтов для UI и моноширинного текста.""" tg_blue: tuple = ("#3390ec", "#3390ec")
tg_blue_hover: tuple = ("#2b7cd4", "#2b7cd4")
bg: tuple = ("#ffffff", "#1e1e1e")
field_bg: tuple = ("#f0f2f5", "#2b2b2b")
field_border: tuple = ("#d6d9dc", "#3a3a3a")
text_primary: tuple = ("#000000", "#ffffff")
text_secondary: tuple = ("#707579", "#aaaaaa")
tg_blue: str = "#3390ec"
tg_blue_hover: str = "#2b7cd4"
bg: str = "#ffffff"
field_bg: str = "#f0f2f5"
field_border: str = "#d6d9dc"
text_primary: str = "#000000"
text_secondary: str = "#707579"
ui_font_family: str = "Sans" ui_font_family: str = "Sans"
mono_font_family: str = "Monospace" mono_font_family: str = "Monospace"
@ -61,17 +52,16 @@ def ctk_theme_for_platform() -> CtkTheme:
def apply_ctk_appearance(ctk: Any) -> None: def apply_ctk_appearance(ctk: Any) -> None:
ctk.set_appearance_mode("light") ctk.set_appearance_mode("auto")
ctk.set_default_color_theme("blue") ctk.set_default_color_theme("blue")
def center_ctk_geometry(root: Any, width: int, height: int) -> None: def center_ctk_geometry(root: Any, width: int, height: int) -> None:
sw = root.winfo_screenwidth() sw = root.winfo_screenwidth()
sh = root.winfo_screenheight() sh = root.winfo_screenheight()
root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}") root.geometry(f"{width}x{height}+{(sw - width) // 2}+{(sh - height) // 2}")
def create_ctk_root( def create_ctk_toplevel(
ctk: Any, ctk: Any,
*, *,
title: str, title: str,
@ -81,21 +71,27 @@ def create_ctk_root(
topmost: bool = True, topmost: bool = True,
after_create: Optional[Callable[[Any], None]] = None, after_create: Optional[Callable[[Any], None]] = None,
) -> Any: ) -> Any:
""" root = ctk.CTkToplevel()
Создаёт CTk: глобальная тема, заголовок, без ресайза, по центру экрана, фон из палитры.
after_create опционально: установка иконки окна (различается по ОС).
"""
_install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
root = ctk.CTk()
root.title(title) root.title(title)
root.resizable(False, False) root.resizable(False, False)
if topmost:
root.attributes("-topmost", True)
center_ctk_geometry(root, width, height) center_ctk_geometry(root, width, height)
root.configure(fg_color=theme.bg) root.configure(fg_color=theme.bg)
if topmost:
root.attributes("-topmost", True)
root.lift()
root.focus_force()
if after_create: if after_create:
after_create(root) _after_id = root.after(300, lambda: after_create(root))
_orig_destroy = root.destroy
def _safe_destroy():
try:
root.after_cancel(_after_id)
except Exception:
pass
_orig_destroy()
root.destroy = _safe_destroy
return root return root
@ -109,4 +105,4 @@ def main_content_frame(
) -> Any: ) -> Any:
frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0) frame = ctk.CTkFrame(root, fg_color=theme.bg, corner_radius=0)
frame.pack(fill="both", expand=True, padx=padx, pady=pady) frame.pack(fill="both", expand=True, padx=padx, pady=pady)
return frame return frame

View File

@ -1,7 +1,3 @@
"""
Всплывающие подсказки для CustomTkinter / tk: задержка, Toplevel без рамки, wrap.
"""
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
@ -9,8 +5,6 @@ from typing import Any, List, Optional
class CtkTooltip: class CtkTooltip:
"""Показ текста при наведении на виджет."""
def __init__( def __init__(
self, self,
widget: Any, widget: Any,
@ -31,6 +25,8 @@ class CtkTooltip:
widget.bind("<Destroy>", self._on_destroy, add="+") widget.bind("<Destroy>", self._on_destroy, add="+")
def _schedule(self, _event: Any = None) -> None: def _schedule(self, _event: Any = None) -> None:
if self.widget is None:
return
self._cancel_after() self._cancel_after()
self._after_id = self.widget.after(self.delay_ms, self._show) self._after_id = self.widget.after(self.delay_ms, self._show)
@ -89,6 +85,7 @@ class CtkTooltip:
def _on_destroy(self, _event: Any = None) -> None: def _on_destroy(self, _event: Any = None) -> None:
self._hide() self._hide()
self.widget = None
def _is_windows() -> bool: def _is_windows() -> bool:
@ -104,11 +101,9 @@ def attach_ctk_tooltip(
delay_ms: int = 450, delay_ms: int = 450,
wraplength: int = 320, wraplength: int = 320,
) -> None: ) -> None:
"""Повесить подсказку на виджет (CTk или tk)."""
CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength) CtkTooltip(widget, text, delay_ms=delay_ms, wraplength=wraplength)
def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None: def attach_tooltip_to_widgets(widgets: List[Any], text: str, **kwargs: Any) -> None:
"""Одна и та же подсказка на несколько виджетов (подпись + поле)."""
for w in widgets: for w in widgets:
attach_ctk_tooltip(w, text, **kwargs) attach_ctk_tooltip(w, text, **kwargs)

View File

@ -1,10 +1,6 @@
"""
Общая разметка CustomTkinter для tray (Windows / Linux): настройки и первый запуск.
Логика сохранения и колбэки остаются в платформенных модулях.
"""
from __future__ import annotations from __future__ import annotations
import os
import webbrowser import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@ -20,15 +16,15 @@ from ui.ctk_theme import (
) )
from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets
# Подсказки для формы настроек (новые пользователи)
_TIP_HOST = ( _TIP_HOST = (
"Адрес, на котором прокси принимает SOCKS5-подключения.\n" "Адрес, на котором прокси принимает подключения.\n"
"Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы"
) )
_TIP_PORT = ( _TIP_PORT = (
"Порт SOCKS5. В Telegram Desktop в настройках прокси должен быть " "Порт прокси. В Telegram Desktop в настройках прокси должен быть "
"указан тот же порт" "указан тот же порт"
) )
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
_TIP_DC = ( _TIP_DC = (
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
"Каждая строка: «номер:IP», например 2:149.154.167.220. " "Каждая строка: «номер:IP», например 2:149.154.167.220. "
@ -53,14 +49,60 @@ _TIP_AUTOSTART = (
"Запускать TG WS Proxy при входе в Windows. " "Запускать TG WS Proxy при входе в Windows. "
"Если вы переместите программу в другую папку, автозапуск сбросится" "Если вы переместите программу в другую папку, автозапуск сбросится"
) )
_TIP_CHECK_UPDATES = ( _TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
"При запуске проверять наличие обновлений"
)
_TIP_SAVE = "Сохранить настройки" _TIP_SAVE = "Сохранить настройки"
_TIP_CANCEL = "Закрыть окно без сохранения изменений" _TIP_CANCEL = "Закрыть окно без сохранения изменений"
# Внутренняя ширина полей относительно ширины окна настроек (см. CONFIG_DIALOG_SIZE) _INNER_W = 396
_CONFIG_FORM_INNER_WIDTH = 396
def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw):
opts = dict(
font=(theme.ui_font_family, 13), corner_radius=radius,
fg_color=theme.bg, border_color=theme.field_border,
border_width=1, text_color=theme.text_primary,
)
if var is not None:
opts["textvariable"] = var
if width:
opts["width"] = width
opts["height"] = height
opts.update(kw)
return ctk.CTkEntry(parent, **opts)
def _checkbox(ctk, parent, theme, text, variable):
return ctk.CTkCheckBox(
parent, text=text, variable=variable,
font=(theme.ui_font_family, 13), text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2, border_color=theme.field_border,
)
def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw):
weight = "bold" if bold else "normal"
return ctk.CTkLabel(
parent, text=text,
font=(theme.ui_font_family, size, weight),
text_color=theme.text_secondary if secondary else theme.text_primary,
anchor="w", **kw,
)
def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False):
col = ctk.CTkFrame(parent, fg_color="transparent")
lbl = _label(ctk, col, theme, label_text)
lbl.pack(anchor="w", pady=(0, 2))
var = ctk.StringVar(value=str(value))
ent = _entry(ctk, col, theme, var=var, width=width)
if pack_fill:
ent.pack(fill="x")
else:
ent.pack(anchor="w")
if tip:
attach_tooltip_to_widgets([lbl, ent, col], tip)
return col, var
def tray_settings_scroll_and_footer( def tray_settings_scroll_and_footer(
@ -68,10 +110,6 @@ def tray_settings_scroll_and_footer(
content_parent: Any, content_parent: Any,
theme: CtkTheme, theme: CtkTheme,
) -> Tuple[Any, Any]: ) -> Tuple[Any, Any]:
"""
Нижняя панель под кнопки и прокручиваемая область для формы (форма не обрезает кнопки).
Возвращает (scroll_frame, footer_frame).
"""
footer = ctk.CTkFrame(content_parent, fg_color=theme.bg) footer = ctk.CTkFrame(content_parent, fg_color=theme.bg)
footer.pack(side="bottom", fill="x") footer.pack(side="bottom", fill="x")
scroll = ctk.CTkScrollableFrame( scroll = ctk.CTkScrollableFrame(
@ -93,22 +131,12 @@ def _config_section(
*, *,
bottom_spacer: int = 6, bottom_spacer: int = 6,
) -> Any: ) -> Any:
"""Заголовок секции и карточка с рамкой для группировки полей."""
wrap = ctk.CTkFrame(parent, fg_color="transparent") wrap = ctk.CTkFrame(parent, fg_color="transparent")
wrap.pack(fill="x", pady=(0, bottom_spacer)) wrap.pack(fill="x", pady=(0, bottom_spacer))
ctk.CTkLabel( _label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2))
wrap,
text=title,
font=(theme.ui_font_family, 12, "bold"),
text_color=theme.text_primary,
anchor="w",
).pack(anchor="w", pady=(0, 2))
card = ctk.CTkFrame( card = ctk.CTkFrame(
wrap, wrap, fg_color=theme.field_bg, corner_radius=10,
fg_color=theme.field_bg, border_width=1, border_color=theme.field_border,
corner_radius=10,
border_width=1,
border_color=theme.field_border,
) )
card.pack(fill="x") card.pack(fill="x")
inner = ctk.CTkFrame(card, fg_color="transparent") inner = ctk.CTkFrame(card, fg_color="transparent")
@ -120,6 +148,7 @@ def _config_section(
class TrayConfigFormWidgets: class TrayConfigFormWidgets:
host_var: Any host_var: Any
port_var: Any port_var: Any
secret_var: Any
dc_textbox: Any dc_textbox: Any
verbose_var: Any verbose_var: Any
adv_entries: List[Any] adv_entries: List[Any]
@ -138,102 +167,67 @@ def install_tray_config_form(
show_autostart: bool = False, show_autostart: bool = False,
autostart_value: bool = False, autostart_value: bool = False,
) -> TrayConfigFormWidgets: ) -> TrayConfigFormWidgets:
"""Поля настроек прокси внутри уже созданного `frame`."""
header = ctk.CTkFrame(frame, fg_color="transparent") header = ctk.CTkFrame(frame, fg_color="transparent")
header.pack(fill="x", pady=(0, 2)) header.pack(fill="x", pady=(0, 2))
ctk.CTkLabel( ctk.CTkLabel(
header, header, text="Настройки прокси",
text="Настройки прокси",
font=(theme.ui_font_family, 17, "bold"), font=(theme.ui_font_family, 17, "bold"),
text_color=theme.text_primary, text_color=theme.text_primary, anchor="w",
anchor="w",
).pack(side="left") ).pack(side="left")
ctk.CTkLabel( ctk.CTkLabel(
header, header, text=f"v{__version__}",
text=f"v{__version__}",
font=(theme.ui_font_family, 12), font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, text_color=theme.text_secondary, anchor="e",
anchor="e",
).pack(side="right") ).pack(side="right")
inner_w = _CONFIG_FORM_INNER_WIDTH conn = _config_section(ctk, frame, theme, "Подключение MTProto")
conn = _config_section(ctk, frame, theme, "Подключение SOCKS5")
host_row = ctk.CTkFrame(conn, fg_color="transparent") host_row = ctk.CTkFrame(conn, fg_color="transparent")
host_row.pack(fill="x") host_row.pack(fill="x")
host_col = ctk.CTkFrame(host_row, fg_color="transparent") host_col, host_var = _labeled_entry(
ctk, host_row, theme, "IP-адрес",
cfg.get("host", default_config["host"]),
tip=_TIP_HOST, width=160, pack_fill=True,
)
host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) host_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
host_lbl = ctk.CTkLabel(
host_col,
text="IP-адрес",
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary,
anchor="w",
)
host_lbl.pack(anchor="w", pady=(0, 2))
host_var = ctk.StringVar(value=cfg.get("host", default_config["host"]))
host_entry = ctk.CTkEntry(
host_col,
textvariable=host_var,
width=160,
height=36,
font=(theme.ui_font_family, 13),
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
)
host_entry.pack(fill="x", pady=(0, 0))
attach_tooltip_to_widgets([host_lbl, host_entry, host_col], _TIP_HOST)
port_col = ctk.CTkFrame(host_row, fg_color="transparent") port_col, port_var = _labeled_entry(
ctk, host_row, theme, "Порт",
cfg.get("port", default_config["port"]),
tip=_TIP_PORT, width=100,
)
port_col.pack(side="left") port_col.pack(side="left")
port_lbl = ctk.CTkLabel(
port_col, secret_row = ctk.CTkFrame(conn, fg_color="transparent")
text="Порт", secret_row.pack(fill="x")
font=(theme.ui_font_family, 12),
text_color=theme.text_secondary, secret_col, secret_var = _labeled_entry(
anchor="w", ctk, secret_row, theme, "Secret",
cfg.get("secret", default_config["secret"]),
tip=_TIP_SECRET, width=160, pack_fill=True,
) )
port_lbl.pack(anchor="w", pady=(0, 2)) secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
port_var = ctk.StringVar(value=str(cfg.get("port", default_config["port"])))
port_entry = ctk.CTkEntry( regen_col = ctk.CTkFrame(secret_row, fg_color="transparent")
port_col, regen_col.pack(side="left", anchor="s")
textvariable=port_var, ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
width=100, ctk.CTkButton(
height=36, regen_col, text="", width=36, height=36,
font=(theme.ui_font_family, 13), font=(theme.ui_font_family, 18), corner_radius=10,
corner_radius=10, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
fg_color=theme.bg, text_color="#ffffff", border_width=1, border_color=theme.field_border,
border_color=theme.field_border, command=lambda: secret_var.set(os.urandom(16).hex()),
border_width=1, ).pack()
text_color=theme.text_primary,
)
port_entry.pack(anchor="w")
attach_tooltip_to_widgets([port_lbl, port_entry, port_col], _TIP_PORT)
dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)")
dc_lbl = ctk.CTkLabel( dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11)
dc_inner,
text="По одному правилу на строку, формат: номер:IP",
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
dc_lbl.pack(anchor="w", pady=(0, 4)) dc_lbl.pack(anchor="w", pady=(0, 4))
dc_textbox = ctk.CTkTextbox( dc_textbox = ctk.CTkTextbox(
dc_inner, dc_inner, width=_INNER_W, height=88,
width=inner_w, font=(theme.mono_font_family, 12), corner_radius=10,
height=88, fg_color=theme.bg, border_color=theme.field_border,
font=(theme.mono_font_family, 12), border_width=1, text_color=theme.text_primary,
corner_radius=10,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
) )
dc_textbox.pack(fill="x") dc_textbox.pack(fill="x")
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
@ -242,18 +236,7 @@ def install_tray_config_form(
log_inner = _config_section(ctk, frame, theme, "Логи и производительность") log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
verbose_cb = ctk.CTkCheckBox( verbose_cb = _checkbox(ctk, log_inner, theme, "Подробное логирование (verbose)", verbose_var)
log_inner,
text="Подробное логирование (verbose)",
variable=verbose_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
verbose_cb.pack(anchor="w", pady=(0, 6)) verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE) attach_ctk_tooltip(verbose_cb, _TIP_VERBOSE)
@ -265,33 +248,17 @@ def install_tray_config_form(
("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), ("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL),
("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), ("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB),
] ]
for lbl, key, tip in adv_rows: for label_text, key, tip in adv_rows:
col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") col = ctk.CTkFrame(adv_frame, fg_color="transparent")
col_frame.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5))
adv_l = ctk.CTkLabel( adv_l = _label(ctk, col, theme, label_text, size=11)
col_frame,
text=lbl,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
)
adv_l.pack(anchor="w", pady=(0, 2)) adv_l.pack(anchor="w", pady=(0, 2))
adv_e = ctk.CTkEntry( adv_e = _entry(
col_frame, ctk, col, theme, width=_INNER_W, height=32, radius=8,
width=inner_w, textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))),
height=32,
font=(theme.ui_font_family, 13),
corner_radius=8,
fg_color=theme.bg,
border_color=theme.field_border,
border_width=1,
text_color=theme.text_primary,
textvariable=ctk.StringVar(
value=str(cfg.get(key, default_config[key]))
),
) )
adv_e.pack(fill="x") adv_e.pack(fill="x")
attach_tooltip_to_widgets([adv_l, adv_e, col_frame], tip) attach_tooltip_to_widgets([adv_l, adv_e, col], tip)
adv_entries = list(adv_frame.winfo_children()) adv_entries = list(adv_frame.winfo_children())
adv_keys = ("buf_kb", "pool_size", "log_max_mb") adv_keys = ("buf_kb", "pool_size", "log_max_mb")
@ -299,22 +266,9 @@ def install_tray_config_form(
upd_inner = _config_section(ctk, frame, theme, "Обновления") upd_inner = _config_section(ctk, frame, theme, "Обновления")
st = get_status() st = get_status()
check_updates_var = ctk.BooleanVar( check_updates_var = ctk.BooleanVar(
value=bool( value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
cfg.get("check_updates", default_config.get("check_updates", True))
)
)
upd_cb = ctk.CTkCheckBox(
upd_inner,
text="Проверять обновления при запуске",
variable=check_updates_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
) )
upd_cb = _checkbox(ctk, upd_inner, theme, "Проверять обновления при запуске", check_updates_var)
upd_cb.pack(anchor="w", pady=(0, 6)) upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES) attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
@ -335,72 +289,38 @@ def install_tray_config_form(
else: else:
upd_status = "Установлена последняя известная версия с GitHub." upd_status = "Установлена последняя известная версия с GitHub."
ctk.CTkLabel( _label(ctk, upd_inner, theme, upd_status, size=11,
upd_inner, justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
text=upd_status,
font=(theme.ui_font_family, 11),
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
open_rel_btn = ctk.CTkButton( ctk.CTkButton(
upd_inner, upd_inner, text="Открыть страницу релиза", height=32,
text="Открыть страницу релиза", font=(theme.ui_font_family, 13), corner_radius=8,
height=32, fg_color=theme.field_bg, hover_color=theme.field_border,
font=(theme.ui_font_family, 13), text_color=theme.text_primary, border_width=1,
corner_radius=8,
fg_color=theme.field_bg,
hover_color=theme.field_border,
text_color=theme.text_primary,
border_width=1,
border_color=theme.field_border, border_color=theme.field_border,
command=lambda u=rel_url: webbrowser.open(u), command=lambda u=rel_url: webbrowser.open(u),
) ).pack(anchor="w")
open_rel_btn.pack(anchor="w")
autostart_var = None autostart_var = None
if show_autostart: if show_autostart:
sys_inner = _config_section( sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4)
ctk, frame, theme, "Запуск Windows", bottom_spacer=4
)
autostart_var = ctk.BooleanVar(value=autostart_value) autostart_var = ctk.BooleanVar(value=autostart_value)
as_cb = ctk.CTkCheckBox( as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var)
sys_inner,
text="Автозапуск при включении компьютера",
variable=autostart_var,
font=(theme.ui_font_family, 13),
text_color=theme.text_primary,
fg_color=theme.tg_blue,
hover_color=theme.tg_blue_hover,
corner_radius=6,
border_width=2,
border_color=theme.field_border,
)
as_cb.pack(anchor="w", pady=(0, 4)) as_cb.pack(anchor="w", pady=(0, 4))
as_hint = ctk.CTkLabel( as_hint = _label(
sys_inner, ctk, sys_inner, theme,
text="Если переместить программу в другую папку, запись автозапуска может сброситься.", "Если переместить программу в другую папку, запись автозапуска может сброситься.",
font=(theme.ui_font_family, 11), size=11, justify="left", wraplength=_INNER_W,
text_color=theme.text_secondary,
anchor="w",
justify="left",
wraplength=inner_w,
) )
as_hint.pack(anchor="w") as_hint.pack(anchor="w")
attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART)
return TrayConfigFormWidgets( return TrayConfigFormWidgets(
host_var=host_var, host_var=host_var, port_var=port_var, secret_var=secret_var,
port_var=port_var, dc_textbox=dc_textbox, verbose_var=verbose_var,
dc_textbox=dc_textbox, adv_entries=adv_entries, adv_keys=adv_keys,
verbose_var=verbose_var, autostart_var=autostart_var, check_updates_var=check_updates_var,
adv_entries=adv_entries,
adv_keys=adv_keys,
autostart_var=autostart_var,
check_updates_var=check_updates_var,
) )
@ -409,7 +329,6 @@ def merge_adv_from_form(
base: Dict[str, Any], base: Dict[str, Any],
default_config: dict, default_config: dict,
) -> None: ) -> None:
"""Дополняет base значениями buf_kb / pool_size / log_max_mb (in-place)."""
for i, key in enumerate(widgets.adv_keys): for i, key in enumerate(widgets.adv_keys):
col_frame = widgets.adv_entries[i] col_frame = widgets.adv_entries[i]
entry = col_frame.winfo_children()[1] entry = col_frame.winfo_children()[1]
@ -428,9 +347,6 @@ def validate_config_form(
*, *,
include_autostart: bool, include_autostart: bool,
) -> Union[dict, str]: ) -> Union[dict, str]:
"""
Возвращает словарь полей конфига или строку ошибки для показа пользователю.
"""
import socket as _sock import socket as _sock
host_val = widgets.host_var.get().strip() host_val = widgets.host_var.get().strip()
@ -456,9 +372,18 @@ def validate_config_form(
except ValueError as e: except ValueError as e:
return str(e) return str(e)
secret_val = widgets.secret_var.get().strip()
if len(secret_val) != 32:
return "Secret должен содержать ровно 32 hex-символа (16 байт)."
try:
bytes.fromhex(secret_val)
except ValueError:
return "Secret должен состоять только из hex-символов (0-9, a-f)."
new_cfg: Dict[str, Any] = { new_cfg: Dict[str, Any] = {
"host": host_val, "host": host_val,
"port": port_val, "port": port_val,
"secret": secret_val,
"dc_ip": lines, "dc_ip": lines,
"verbose": widgets.verbose_var.get(), "verbose": widgets.verbose_var.get(),
} }
@ -517,12 +442,11 @@ def populate_first_run_window(
*, *,
host: str, host: str,
port: int, port: int,
secret: str,
on_done: Callable[[bool], None], on_done: Callable[[bool], None],
) -> None: ) -> None:
""" link_host = tg_ws_proxy.get_link_host(host)
Содержимое окна первого запуска. on_done(open_in_telegram) по «Начать» и по закрытию окна. tg_url = f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
"""
tg_url = f"tg://socks?server={host}&port={port}"
fpx, fpy = FIRST_RUN_FRAME_PAD fpx, fpy = FIRST_RUN_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@ -541,18 +465,35 @@ def populate_first_run_window(
("Как подключить Telegram Desktop:", True), ("Как подключить Telegram Desktop:", True),
(" Автоматически:", True), (" Автоматически:", True),
(" ПКМ по иконке в трее → «Открыть в Telegram»", False), (" ПКМ по иконке в трее → «Открыть в Telegram»", False),
(f" Или ссылка: {tg_url}", False), (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False),
("\n Вручную:", True), ("\n Вручную:", True),
(" Настройки → Продвинутые → Тип подключения → Прокси", False), (" Настройки → Продвинутые → Тип подключения → Прокси", False),
(f" SOCKS5 → {host} : {port} (без логина/пароля)", False), (f" MTProto → {link_host} : {port}", False),
(f" Secret: dd{secret}", False),
] ]
textbox = ctk.CTkTextbox(
frame,
font=(theme.ui_font_family, 13),
fg_color=theme.bg,
border_width=0,
text_color=theme.text_primary,
activate_scrollbars=False,
wrap="word",
height=275,
)
textbox._textbox.tag_configure("bold", font=(theme.ui_font_family, 13, "bold"))
textbox._textbox.configure(spacing1=1, spacing3=1)
for text, bold in sections: for text, bold in sections:
weight = "bold" if bold else "normal" if text.startswith("\n"):
ctk.CTkLabel(frame, text=text, textbox.insert("end", "\n")
font=(theme.ui_font_family, 13, weight), text = text[1:]
text_color=theme.text_primary, if bold:
anchor="w", justify="left").pack(anchor="w", pady=1) textbox.insert("end", text + "\n", "bold")
else:
textbox.insert("end", text + "\n")
textbox.configure(state="disabled")
textbox.pack(anchor="w", fill="x")
ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() ctk.CTkFrame(frame, fg_color="transparent", height=16).pack()
@ -560,12 +501,8 @@ def populate_first_run_window(
corner_radius=0).pack(fill="x", pady=(0, 12)) corner_radius=0).pack(fill="x", pady=(0, 12))
auto_var = ctk.BooleanVar(value=True) auto_var = ctk.BooleanVar(value=True)
ctk.CTkCheckBox(frame, text="Открыть прокси в Telegram сейчас", _checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас",
variable=auto_var, font=(theme.ui_font_family, 13), auto_var).pack(anchor="w", pady=(0, 16))
text_color=theme.text_primary,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
corner_radius=6, border_width=2,
border_color=theme.field_border).pack(anchor="w", pady=(0, 16))
def on_ok(): def on_ok():
on_done(auto_var.get()) on_done(auto_var.get())

View File

@ -5,10 +5,11 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import os
from typing import Any, Dict from typing import Any, Dict
_TRAY_DEFAULTS_COMMON: Dict[str, Any] = { _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"port": 1080, "port": 1443,
"host": "127.0.0.1", "host": "127.0.0.1",
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False, "verbose": False,
@ -20,8 +21,10 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
def default_tray_config() -> Dict[str, Any]: def default_tray_config() -> Dict[str, Any]:
"""Новая копия конфига по умолчанию для текущей ОС."""
cfg = dict(_TRAY_DEFAULTS_COMMON) cfg = dict(_TRAY_DEFAULTS_COMMON)
cfg["secret"] = os.urandom(16).hex()
if sys.platform == "win32": if sys.platform == "win32":
cfg["autostart"] = False cfg["autostart"] = False
return cfg return cfg

460
utils/tray_common.py Normal file
View File

@ -0,0 +1,460 @@
from __future__ import annotations
import asyncio
import json
import logging
import logging.handlers
import os
import socket as _socket
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple
import psutil
import proxy.tg_ws_proxy as tg_ws_proxy
from proxy import __version__
from utils.default_config import default_tray_config
log = logging.getLogger("tg-ws-tray")
APP_NAME = "TgWsProxy"
def _app_dir() -> Path:
if sys.platform == "win32":
return Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
APP_DIR = _app_dir()
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done_mtproto"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG: Dict[str, Any] = default_tray_config()
IS_FROZEN = bool(getattr(sys, "frozen", False))
def ensure_dirs() -> None:
APP_DIR.mkdir(parents=True, exist_ok=True)
# single-instance lock
_lock_file_path: Optional[Path] = None
def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
try:
lock_ct = float(meta.get("create_time", 0.0))
if lock_ct > 0 and abs(lock_ct - proc.create_time()) > 1.0:
return False
except Exception:
return False
if IS_FROZEN:
return APP_NAME.lower() in proc.name().lower()
try:
for arg in proc.cmdline():
if script_hint in arg:
return True
except Exception:
pass
return False
def acquire_lock(script_hint: str = "") -> bool:
global _lock_file_path
ensure_dirs()
for f in list(APP_DIR.glob("*.lock")):
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
meta: dict = {}
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
pass
try:
if _same_process(meta, psutil.Process(pid), script_hint):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
lock_file.write_text(
json.dumps({"create_time": proc.create_time()}, ensure_ascii=False),
encoding="utf-8",
)
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def release_lock() -> None:
global _lock_file_path
if _lock_file_path:
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
# config
def load_config() -> dict:
ensure_dirs()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in DEFAULT_CONFIG.items():
data.setdefault(k, v)
return data
except Exception as exc:
log.warning("Failed to load config: %s", exc)
return dict(DEFAULT_CONFIG)
def save_config(cfg: dict) -> None:
ensure_dirs()
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# logging
_LOG_FMT_FILE = "%(asctime)s %(levelname)-5s %(name)s %(message)s"
_LOG_FMT_CONSOLE = "%(asctime)s %(levelname)-5s %(message)s"
def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
ensure_dirs()
level = logging.DEBUG if verbose else logging.INFO
root = logging.getLogger()
root.setLevel(level)
fh = logging.handlers.RotatingFileHandler(
str(LOG_FILE),
maxBytes=max(32 * 1024, int(log_max_mb * 1024 * 1024)),
backupCount=0,
encoding="utf-8",
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
root.addHandler(fh)
if not IS_FROZEN:
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
root.addHandler(ch)
# icon
def make_icon_image(size: int = 64, *, color: Tuple[int, ...] = (0, 136, 204, 255)):
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin], fill=color)
for path in _font_paths():
try:
font = ImageFont.truetype(path, size=int(size * 0.55))
break
except Exception:
continue
else:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
"T",
fill=(255, 255, 255, 255),
font=font,
)
return img
def _font_paths():
if sys.platform == "win32":
return ["arial.ttf"]
if sys.platform == "darwin":
return ["/System/Library/Fonts/Helvetica.ttc"]
return [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
]
def load_icon():
from PIL import Image
icon_path = Path(__file__).parents[1] / "icon.ico"
if icon_path.exists():
try:
return Image.open(str(icon_path))
except Exception:
pass
return make_icon_image(64)
# proxy lifecycle
_proxy_thread: Optional[threading.Thread] = None
_async_stop: Optional[Tuple[asyncio.AbstractEventLoop, asyncio.Event]] = None
def _run_proxy_thread(on_port_busy: Callable[[str], None]) -> None:
global _async_stop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
stop_ev = asyncio.Event()
_async_stop = (loop, stop_ev)
try:
loop.run_until_complete(tg_ws_proxy._run(stop_event=stop_ev))
except Exception as exc:
log.error("Proxy thread crashed: %s", exc)
if "Address already in use" in str(exc) or "10048" in str(exc):
on_port_busy(
"Не удалось запустить прокси:\n"
"Порт уже используется другим приложением.\n\n"
"Закройте приложение, использующее этот порт, "
"или измените порт в настройках прокси и перезапустите."
)
finally:
loop.close()
_async_stop = None
def apply_proxy_config(cfg: dict) -> bool:
dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])
try:
dc_redirects = tg_ws_proxy.parse_dc_ip_list(dc_ip_list)
except ValueError as e:
log.error("Bad config dc_ip: %s", e)
return False
pc = tg_ws_proxy.proxy_config
pc.port = cfg.get("port", DEFAULT_CONFIG["port"])
pc.host = cfg.get("host", DEFAULT_CONFIG["host"])
pc.secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
pc.dc_redirects = dc_redirects
pc.buffer_size = max(4, cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])) * 1024
pc.pool_size = max(0, cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]))
return True
def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
global _proxy_thread
if _proxy_thread and _proxy_thread.is_alive():
log.info("Proxy already running")
return
if not apply_proxy_config(cfg):
on_error("Ошибка конфигурации DC → IP.")
return
pc = tg_ws_proxy.proxy_config
log.info("Starting proxy on %s:%d ...", pc.host, pc.port)
_proxy_thread = threading.Thread(
target=_run_proxy_thread, args=(on_error,), daemon=True, name="proxy"
)
_proxy_thread.start()
def stop_proxy() -> None:
global _proxy_thread, _async_stop
if _async_stop:
loop, stop_ev = _async_stop
loop.call_soon_threadsafe(stop_ev.set)
if _proxy_thread:
_proxy_thread.join(timeout=5)
_proxy_thread = None
log.info("Proxy stopped")
def restart_proxy(cfg: dict, on_error: Callable[[str], None]) -> None:
log.info("Restarting proxy...")
stop_proxy()
time.sleep(0.3)
start_proxy(cfg, on_error)
def tg_proxy_url(cfg: dict) -> str:
host = cfg.get("host", DEFAULT_CONFIG["host"])
port = cfg.get("port", DEFAULT_CONFIG["port"])
secret = cfg.get("secret", DEFAULT_CONFIG["secret"])
link_host = tg_ws_proxy.get_link_host(host)
return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}"
_IPV6_WARNING = (
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз."
)
def _has_ipv6() -> bool:
try:
for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6):
ip = addr[4][0]
if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"):
return True
except Exception:
pass
try:
s = _socket.socket(_socket.AF_INET6, _socket.SOCK_STREAM)
s.bind(("::1", 0))
s.close()
return True
except Exception:
return False
def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
ensure_dirs()
if IPV6_WARN_MARKER.exists() or not _has_ipv6():
return
IPV6_WARN_MARKER.touch()
threading.Thread(
target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"),
daemon=True,
).start()
# update check
def maybe_notify_update(
cfg: dict,
is_exiting: Callable[[], bool],
ask_open: Callable[[str, str], bool],
) -> None:
if not cfg.get("check_updates", True):
return
def _work():
time.sleep(1.5)
if is_exiting():
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
import webbrowser
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if ask_open(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
# ctk thread (windows / linux)
_ctk_root: Any = None
_ctk_root_ready = threading.Event()
def ensure_ctk_thread(ctk: Any) -> bool:
global _ctk_root
if ctk is None:
return False
if _ctk_root_ready.is_set():
return True
def _run():
global _ctk_root
from ui.ctk_theme import apply_ctk_appearance, install_tkinter_variable_del_guard
install_tkinter_variable_del_guard()
apply_ctk_appearance(ctk)
_ctk_root = ctk.CTk()
_ctk_root.withdraw()
_ctk_root_ready.set()
_ctk_root.mainloop()
threading.Thread(target=_run, daemon=True, name="ctk-root").start()
_ctk_root_ready.wait(timeout=5.0)
return _ctk_root is not None
def ctk_run_dialog(build_fn: Callable[[threading.Event], None]) -> None:
if _ctk_root is None:
return
done = threading.Event()
def _invoke():
try:
build_fn(done)
except Exception:
log.exception("CTk dialog failed")
done.set()
_ctk_root.after(0, _invoke)
done.wait()
import gc
gc.collect()
def quit_ctk() -> None:
if _ctk_root is not None:
try:
_ctk_root.after(0, _ctk_root.quit)
except Exception:
pass
# common bootstrap
def bootstrap(cfg: dict) -> None:
save_config(cfg)
if LOG_FILE.exists():
try:
LOG_FILE.unlink()
except Exception:
pass
setup_logging(
cfg.get("verbose", False),
log_max_mb=cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]),
)
log.info("TG WS Proxy версия %s starting", __version__)
log.info("Config: %s", cfg)
log.info("Log file: %s", LOG_FILE)

View File

@ -16,7 +16,7 @@ from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
REPO = "Dark-Avery/tg-ws-proxy" REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"

View File

@ -1,18 +1,12 @@
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import ipaddress
import json
import logging
import logging.handlers
import os import os
import winreg
import psutil
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
import ipaddress import winreg
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -32,169 +26,62 @@ except ImportError:
ctk = None ctk = None
try: try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image
except ImportError: except ImportError:
Image = ImageDraw = ImageFont = None Image = None
import proxy.tg_ws_proxy as tg_ws_proxy import proxy.tg_ws_proxy as tg_ws_proxy
from proxy.app_runtime import ProxyAppRuntime
from proxy import __version__ from utils.tray_common import (
from utils.default_config import default_tray_config APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_buttons, install_tray_config_form,
install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer,
populate_first_run_window,
tray_settings_scroll_and_footer,
validate_config_form, validate_config_form,
) )
from ui.ctk_theme import ( from ui.ctk_theme import (
CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE,
CONFIG_DIALOG_SIZE, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame,
FIRST_RUN_SIZE,
create_ctk_root,
ctk_theme_for_platform,
main_content_frame,
) )
IS_FROZEN = bool(getattr(sys, "frozen", False))
APP_NAME = "TgWsProxy"
APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME
CONFIG_FILE = APP_DIR / "config.json"
LOG_FILE = APP_DIR / "proxy.log"
FIRST_RUN_MARKER = APP_DIR / ".first_run_done"
IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned"
DEFAULT_CONFIG = default_tray_config()
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting: bool = False _exiting = False
_lock_file_path: Optional[Path] = None
log = logging.getLogger("tg-ws-tray") ICON_PATH = str(Path(__file__).parent / "icon.ico")
_runtime = ProxyAppRuntime(
APP_DIR,
default_config=DEFAULT_CONFIG,
logger_name="tg-ws-tray",
on_error=lambda text: _show_error(text),
)
CONFIG_FILE = _runtime.config_file
LOG_FILE = _runtime.log_file
_user32 = ctypes.windll.user32 # win32 dialogs
_user32.MessageBoxW.argtypes = [
ctypes.c_void_p, _u32 = ctypes.windll.user32
ctypes.c_wchar_p, _u32.MessageBoxW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint]
ctypes.c_wchar_p, _u32.MessageBoxW.restype = ctypes.c_int
ctypes.c_uint,
] _MB_OK_ERR = 0x10
_user32.MessageBoxW.restype = ctypes.c_int _MB_OK_INFO = 0x40
_MB_YESNO_Q = 0x24
_IDYES = 6
def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None:
try: _u32.MessageBoxW(None, text, title, _MB_OK_ERR)
lock_ct = float(lock_meta.get("create_time", 0.0))
proc_ct = float(proc.create_time())
if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0:
return False
except Exception:
return False
try:
for arg in proc.cmdline():
if "windows.py" in arg:
return True
except Exception:
pass
frozen = bool(getattr(sys, "frozen", False))
if frozen:
return (
os.path.basename(sys.executable).lower() == proc.name().lower()
)
return False
def _release_lock(): def _show_info(text: str, title: str = "TG WS Proxy") -> None:
global _lock_file_path _u32.MessageBoxW(None, text, title, _MB_OK_INFO)
if not _lock_file_path:
return
try:
_lock_file_path.unlink(missing_ok=True)
except Exception:
pass
_lock_file_path = None
def _acquire_lock() -> bool: def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool:
global _lock_file_path return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES
_ensure_dirs()
lock_files = list(APP_DIR.glob("*.lock"))
for f in lock_files:
pid = None
meta: dict = {}
try:
pid = int(f.stem)
except Exception:
f.unlink(missing_ok=True)
continue
try:
raw = f.read_text(encoding="utf-8").strip()
if raw:
meta = json.loads(raw)
except Exception:
meta = {}
try:
proc = psutil.Process(pid)
if _same_process(meta, proc):
return False
except Exception:
pass
f.unlink(missing_ok=True)
lock_file = APP_DIR / f"{os.getpid()}.lock"
try:
proc = psutil.Process(os.getpid())
payload = {
"create_time": proc.create_time(),
}
lock_file.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception:
lock_file.touch()
_lock_file_path = lock_file
return True
def _ensure_dirs(): # autostart (registry)
_runtime.ensure_dirs()
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def load_config() -> dict:
return _runtime.load_config()
def save_config(cfg: dict):
_runtime.save_config(cfg)
def setup_logging(verbose: bool = False, log_max_mb: float = 5):
_runtime.setup_logging(verbose, log_max_mb=log_max_mb)
def _autostart_reg_name() -> str:
return APP_NAME
def _supports_autostart() -> bool: def _supports_autostart() -> bool:
@ -207,408 +94,213 @@ def _autostart_command() -> str:
def is_autostart_enabled() -> bool: def is_autostart_enabled() -> bool:
try: try:
with winreg.OpenKey( with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_READ) as k:
winreg.HKEY_CURRENT_USER, val, _ = winreg.QueryValueEx(k, APP_NAME)
r"Software\Microsoft\Windows\CurrentVersion\Run", return str(val).strip() == _autostart_command().strip()
0, except (FileNotFoundError, OSError):
winreg.KEY_READ,
) as k:
val, _ = winreg.QueryValueEx(k, _autostart_reg_name())
stored = str(val).strip()
expected = _autostart_command().strip()
return stored == expected
except FileNotFoundError:
return False
except OSError:
return False return False
def set_autostart_enabled(enabled: bool) -> None: def set_autostart_enabled(enabled: bool) -> None:
try: try:
with winreg.CreateKey( with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as k:
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Run",
) as k:
if enabled: if enabled:
winreg.SetValueEx( winreg.SetValueEx(k, APP_NAME, 0, winreg.REG_SZ, _autostart_command())
k,
_autostart_reg_name(),
0,
winreg.REG_SZ,
_autostart_command(),
)
else: else:
try: try:
winreg.DeleteValue(k, _autostart_reg_name()) winreg.DeleteValue(k, APP_NAME)
except FileNotFoundError: except FileNotFoundError:
pass pass
except OSError as exc: except OSError as exc:
log.error("Failed to update autostart: %s", exc) log.error("Failed to update autostart: %s", exc)
_show_error( _show_error(
"Не удалось изменить автозапуск.\n\n" "Не удалось изменить автозапуск.\n\n"
"Попробуйте запустить приложение от имени пользователя с правами на реестр.\n\n" "Попробуйте запустить приложение от имени пользователя "
f"Ошибка: {exc}" f"с правами на реестр.\n\nОшибка: {exc}"
) )
def _make_icon_image(size: int = 64): # tray callbacks
if Image is None:
raise RuntimeError("Pillow is required for tray icon")
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = 2
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 136, 204, 255))
try:
font = ImageFont.truetype("arial.ttf", size=int(size * 0.55))
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), "T", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (size - tw) // 2 - bbox[0]
ty = (size - th) // 2 - bbox[1]
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
return img def _on_open_in_telegram(icon=None, item=None) -> None:
url = tg_proxy_url(_config)
def _load_icon():
icon_path = Path(__file__).parent / "icon.ico"
if icon_path.exists() and Image:
try:
return Image.open(str(icon_path))
except Exception:
pass
return _make_icon_image()
def start_proxy():
_runtime.start_proxy(_config)
def stop_proxy():
_runtime.stop_proxy()
def restart_proxy():
_runtime.restart_proxy()
def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"):
_user32.MessageBoxW(None, text, title, 0x10)
def _show_info(text: str, title: str = "TG WS Proxy"):
_user32.MessageBoxW(None, text, title, 0x40)
def _ask_open_release_page(latest_version: str, url: str) -> bool:
"""Win32 Yes/No: открыть страницу релиза."""
MB_YESNO = 0x4
MB_ICONQUESTION = 0x20
IDYES = 6
text = (
f"Доступна новая версия: {latest_version}\n\n"
f"Открыть страницу релиза в браузере?"
)
r = _user32.MessageBoxW(
None,
text,
"TG WS Proxy — обновление",
MB_YESNO | MB_ICONQUESTION,
)
return r == IDYES
def _maybe_notify_update_async():
"""
Фоновая проверка GitHub Releases и уведомление (не блокирует трей).
"""
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if _ask_open_release_page(str(ver), url):
webbrowser.open(url)
except Exception as exc:
log.debug("Update check failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _on_open_in_telegram(icon=None, item=None):
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
url = f"tg://socks?server={host}&port={port}"
log.info("Opening %s", url) log.info("Opening %s", url)
try: try:
result = webbrowser.open(url) if not webbrowser.open(url):
if not result: raise RuntimeError
raise RuntimeError("webbrowser.open returned False")
except Exception: except Exception:
log.info("Browser open failed, copying to clipboard") log.info("Browser open failed, copying to clipboard")
if pyperclip is None: if pyperclip is None:
_show_error( _show_error(
"Не удалось открыть Telegram автоматически.\n\n" "Не удалось открыть Telegram автоматически.\n\n"
f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}") f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}"
)
return return
try: try:
pyperclip.copy(url) pyperclip.copy(url)
_show_info( _show_info(
f"Не удалось открыть Telegram автоматически.\n\n" "Не удалось открыть Telegram автоматически.\n\n"
f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}"
"TG WS Proxy") )
except Exception as exc: except Exception as exc:
log.error("Clipboard copy failed: %s", exc) log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}") _show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_restart(icon=None, item=None): def _on_copy_link(icon=None, item=None) -> None:
threading.Thread(target=restart_proxy, daemon=True).start() url = tg_proxy_url(_config)
log.info("Copying link: %s", url)
if pyperclip is None:
_show_error(
"Установите пакет pyperclip для копирования в буфер обмена."
)
return
try:
pyperclip.copy(url)
except Exception as exc:
log.error("Clipboard copy failed: %s", exc)
_show_error(f"Не удалось скопировать ссылку:\n{exc}")
def _on_edit_config(icon=None, item=None): def _on_restart(icon=None, item=None) -> None:
threading.Thread(
target=lambda: restart_proxy(_config, _show_error), daemon=True
).start()
def _on_edit_config(icon=None, item=None) -> None:
threading.Thread(target=_edit_config_dialog, daemon=True).start() threading.Thread(target=_edit_config_dialog, daemon=True).start()
def _edit_config_dialog(): def _on_open_logs(icon=None, item=None) -> None:
if ctk is None:
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled()
# Make sure that the autostart key is removed if autostart
# is disabled, even if the executable file is moved.
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
if _supports_autostart():
h += 100
icon_path = str(Path(__file__).parent / "icon.ico")
root = create_ctk_root(
ctk,
title="TG WS Proxy — Настройки",
width=w,
height=h,
theme=theme,
after_create=lambda r: r.iconbitmap(icon_path),
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk,
scroll,
theme,
cfg,
DEFAULT_CONFIG,
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
)
def on_save():
merged = validate_config_form(
widgets,
DEFAULT_CONFIG,
include_autostart=_supports_autostart(),
)
if isinstance(merged, str):
_show_error(merged)
return
new_cfg = merged
save_config(new_cfg)
_config.update(new_cfg)
log.info("Config saved: %s", new_cfg)
if _supports_autostart():
set_autostart_enabled(bool(new_cfg.get("autostart", False)))
_tray_icon.menu = _build_menu()
# Win32 MessageBox из того же потока, что и mainloop CTk, блокирует обработку Tcl/Tk
# и даёт зависание; tkinter.messagebox согласован с циклом окна.
from tkinter import messagebox
if messagebox.askyesno("Перезапустить?",
"Настройки сохранены.\n\n"
"Перезапустить прокси сейчас?",
parent=root):
root.destroy()
restart_proxy()
else:
root.destroy()
def on_cancel():
root.destroy()
install_tray_config_buttons(
ctk, footer, theme, on_save=on_save, on_cancel=on_cancel)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _on_open_logs(icon=None, item=None):
log.info("Opening log file: %s", LOG_FILE) log.info("Opening log file: %s", LOG_FILE)
if LOG_FILE.exists(): if LOG_FILE.exists():
os.startfile(str(LOG_FILE)) os.startfile(str(LOG_FILE))
else: else:
_show_info("Файл логов ещё не создан.", "TG WS Proxy") _show_info("Файл логов ещё не создан.")
def _on_exit(icon=None, item=None): def _on_exit(icon=None, item=None) -> None:
global _exiting global _exiting
if _exiting: if _exiting:
os._exit(0) os._exit(0)
return return
_exiting = True _exiting = True
log.info("User requested exit") log.info("User requested exit")
quit_ctk()
def _force_exit(): threading.Thread(target=lambda: (time.sleep(3), os._exit(0)), daemon=True, name="force-exit").start()
time.sleep(3)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True, name="force-exit").start()
if icon: if icon:
icon.stop() icon.stop()
# settings dialog
def _show_first_run(): def _edit_config_dialog() -> None:
_ensure_dirs() if not ensure_ctk_thread(ctk):
_show_error("customtkinter не установлен.")
return
cfg = dict(_config)
cfg["autostart"] = is_autostart_enabled()
if _supports_autostart() and not cfg["autostart"]:
set_autostart_enabled(False)
def _build(done: threading.Event) -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
if _supports_autostart():
h += 100
root = create_ctk_toplevel(
ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme)
widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
)
def _finish() -> None:
root.destroy()
done.set()
def on_save() -> None:
from tkinter import messagebox
merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart())
if isinstance(merged, str):
messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root)
return
save_config(merged)
_config.update(merged)
log.info("Config saved: %s", merged)
if _supports_autostart():
set_autostart_enabled(bool(merged.get("autostart", False)))
_tray_icon.menu = _build_menu()
do_restart = messagebox.askyesno(
"Перезапустить?",
"Настройки сохранены.\n\nПерезапустить прокси сейчас?",
parent=root,
)
_finish()
if do_restart:
threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start()
root.protocol("WM_DELETE_WINDOW", _finish)
install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_finish)
ctk_run_dialog(_build)
# first run
def _show_first_run() -> None:
ensure_dirs()
if FIRST_RUN_MARKER.exists(): if FIRST_RUN_MARKER.exists():
return return
if not ensure_ctk_thread(ctk):
FIRST_RUN_MARKER.touch()
return
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
secret = _config.get("secret", DEFAULT_CONFIG["secret"])
if ctk is None: def _build(done: threading.Event) -> None:
FIRST_RUN_MARKER.touch() theme = ctk_theme_for_platform()
return w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title="TG WS Proxy", width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
theme = ctk_theme_for_platform() def on_done(open_tg: bool) -> None:
icon_path = str(Path(__file__).parent / "icon.ico") FIRST_RUN_MARKER.touch()
w, h = FIRST_RUN_SIZE root.destroy()
root = create_ctk_root( done.set()
ctk, if open_tg:
title="TG WS Proxy", _on_open_in_telegram()
width=w,
height=h,
theme=theme,
after_create=lambda r: r.iconbitmap(icon_path),
)
def on_done(open_tg: bool): populate_first_run_window(ctk, root, theme, host=host, port=port, secret=secret, on_done=on_done)
FIRST_RUN_MARKER.touch()
root.destroy()
if open_tg:
_on_open_in_telegram()
populate_first_run_window( ctk_run_dialog(_build)
ctk, root, theme, host=host, port=port, on_done=on_done)
try:
root.mainloop()
finally:
import tkinter as tk
try:
if root.winfo_exists():
root.destroy()
except tk.TclError:
pass
def _has_ipv6_enabled() -> bool: # tray menu
import socket as _sock
try:
addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6)
for addr in addrs:
ip = addr[4][0]
if not ip or ip.startswith("::1"):
continue
try:
if ipaddress.IPv6Address(ip).is_link_local:
continue
except ValueError:
if ip.startswith("fe80:"):
continue
return True
except Exception:
pass
try:
s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM)
s.bind(('::1', 0))
s.close()
return True
except Exception:
return False
def _check_ipv6_warning():
_ensure_dirs()
if IPV6_WARN_MARKER.exists():
return
if not _has_ipv6_enabled():
return
IPV6_WARN_MARKER.touch()
threading.Thread(target=_show_ipv6_dialog, daemon=True).start()
def _show_ipv6_dialog():
_show_info(
"На вашем компьютере включена поддержка подключения по IPv6.\n\n"
"Telegram может пытаться подключаться через IPv6, "
"что не поддерживается и может привести к ошибкам.\n\n"
"Если прокси не работает или в логах присутствуют ошибки, "
"связанные с попытками подключения по IPv6 - "
"попробуйте отключить в настройках прокси Telegram попытку соединения "
"по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 "
"в системе.\n\n"
"Это предупреждение будет показано только один раз.",
"TG WS Proxy")
def _build_menu(): def _build_menu():
if pystray is None: if pystray is None:
return None return None
host = _config.get("host", DEFAULT_CONFIG["host"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = tg_ws_proxy.get_link_host(host)
return pystray.Menu( return pystray.Menu(
pystray.MenuItem( pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
f"Открыть в Telegram ({host}:{port})", pystray.MenuItem("Скопировать ссылку", _on_copy_link),
_on_open_in_telegram,
default=True),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Перезапустить прокси", _on_restart), pystray.MenuItem("Перезапустить прокси", _on_restart),
pystray.MenuItem("Настройки...", _on_edit_config), pystray.MenuItem("Настройки...", _on_edit_config),
@ -618,23 +310,17 @@ def _build_menu():
) )
def run_tray(): # entry point
def run_tray() -> None:
global _tray_icon, _config global _tray_icon, _config
_config = _runtime.prepare() _config = load_config()
_runtime.reset_log_file() bootstrap(_config)
setup_logging(_config.get("verbose", False),
log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"]))
log.info("TG WS Proxy версия %s, tray app starting", __version__)
log.info("Config: %s", _config)
log.info("Log file: %s", LOG_FILE)
if pystray is None or Image is None or ctk is None: if pystray is None or Image is None or ctk is None:
log.error( log.error("pystray, Pillow or customtkinter not installed; running in console mode")
"pystray, Pillow or customtkinter not installed; " start_proxy(_config, _show_error)
"running in console mode")
start_proxy()
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
@ -642,20 +328,12 @@ def run_tray():
stop_proxy() stop_proxy()
return return
start_proxy() start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
_maybe_notify_update_async()
_show_first_run() _show_first_run()
_check_ipv6_warning() check_ipv6_warning(_show_info)
icon_image = _load_icon()
_tray_icon = pystray.Icon(
APP_NAME,
icon_image,
"TG WS Proxy",
menu=_build_menu())
_tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu())
log.info("Tray icon running") log.info("Tray icon running")
_tray_icon.run() _tray_icon.run()
@ -663,15 +341,14 @@ def run_tray():
log.info("Tray app exited") log.info("Tray app exited")
def main(): def main() -> None:
if not _acquire_lock(): if not acquire_lock("windows.py"):
_show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0]))
return return
try: try:
run_tray() run_tray()
finally: finally:
_release_lock() release_lock()
if __name__ == "__main__": if __name__ == "__main__":