mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-22 23:41:44 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df98baf961 | ||
|
|
34dde32033 | ||
|
|
b8bd062663 | ||
|
|
8e1e3fcc45 | ||
|
|
097bb9d0b7 | ||
|
|
19fbf7494a | ||
|
|
4b0bc2f4d2 | ||
|
|
7850e1f5b4 | ||
|
|
63d5bafd3e | ||
|
|
7eaba0b29c | ||
|
|
6c94d3a39d | ||
|
|
746cd66b35 | ||
|
|
e5d8ff7769 | ||
|
|
3ee82e5114 | ||
|
|
db1308e3f5 | ||
|
|
6231499c39 | ||
|
|
826554abfb | ||
|
|
7f44c524c8 | ||
|
|
6310fcd6eb | ||
|
|
081b150b3d | ||
|
|
15001980dc | ||
|
|
da4b521aba |
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
@@ -29,15 +29,43 @@ jobs:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Setup MSVC 14.40 toolset
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
toolset: 14.40
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install .
|
||||
|
||||
- name: Install pyinstaller
|
||||
run: pip install "pyinstaller==6.13.0"
|
||||
- name: Build PyInstaller bootloader from source
|
||||
run: |
|
||||
pip install "pyinstaller==6.16.0" --no-binary pyinstaller
|
||||
env:
|
||||
PYINSTALLER_COMPILE_BOOTLOADER: 1
|
||||
|
||||
- name: Build EXE with PyInstaller
|
||||
run: pyinstaller packaging/windows.spec --noconfirm
|
||||
|
||||
- name: Strip Rich PE header
|
||||
shell: bash
|
||||
run: |
|
||||
python -c "
|
||||
import struct, pathlib
|
||||
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||
data = bytearray(exe.read_bytes())
|
||||
rich = data.find(b'Rich')
|
||||
if rich == -1:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||
exe.write_bytes(data)
|
||||
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||
"
|
||||
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||
|
||||
@@ -71,6 +99,26 @@ jobs:
|
||||
- name: Build EXE with PyInstaller
|
||||
run: pyinstaller packaging/windows.spec --noconfirm
|
||||
|
||||
- name: Strip Rich PE header
|
||||
shell: bash
|
||||
run: |
|
||||
python -c "
|
||||
import struct, pathlib
|
||||
exe = pathlib.Path('dist/TgWsProxy.exe')
|
||||
data = bytearray(exe.read_bytes())
|
||||
rich = data.find(b'Rich')
|
||||
if rich == -1:
|
||||
raise SystemExit('Rich header not found')
|
||||
ck = struct.unpack_from('<I', data, rich + 4)[0]
|
||||
dans = struct.pack('<I', 0x536E6144 ^ ck)
|
||||
ds = data.find(dans)
|
||||
if ds == -1:
|
||||
raise SystemExit('DanS marker not found')
|
||||
data[ds:rich + 8] = b'\x00' * (rich + 8 - ds)
|
||||
exe.write_bytes(data)
|
||||
print(f'Stripped Rich header: offset {ds}..{rich+8}')
|
||||
"
|
||||
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
|
||||
@@ -145,7 +193,7 @@ jobs:
|
||||
|
||||
python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl
|
||||
python3.12 -m pip install .
|
||||
python3.12 -m pip install pyinstaller==6.13.0
|
||||
python3.12 -m pip install pyinstaller==6.16.0
|
||||
|
||||
- name: Create macOS icon from ICO
|
||||
run: |
|
||||
@@ -247,7 +295,7 @@ jobs:
|
||||
run: |
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install .
|
||||
.venv/bin/pip install "pyinstaller==6.13.0"
|
||||
.venv/bin/pip install "pyinstaller==6.16.0"
|
||||
|
||||
- name: Build binary with PyInstaller
|
||||
run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN apt-get update \
|
||||
WORKDIR /app
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY proxy ./proxy
|
||||
COPY README.md LICENSE ./
|
||||
COPY docs/README.md LICENSE ./
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
29
docs/CfProxy.md
Normal file
29
docs/CfProxy.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Cloudflare Proxy
|
||||
|
||||
Для недоступных датацентров можно использовать альтернативный бесплатный метод подключения - проксирование через Cloudflare. **Для работы нужен только домен**. В приложении есть домен по умолчанию, но его можно (и лучше) заменить на свой.
|
||||
|
||||
Прокси возвращает доступ к тому, что до этого не грузило (реакциям, некоторым стикерам). Если у вас до этого не грузило видео/фото на аккаунте без премиума, то уберите `2:149.154.167.220` из `DC->IP` блока в настройках. Если CF-прокси у вас работает - медиа сновая начнёт грузиться.
|
||||
|
||||
## Зачем мне настраивать свой домен?
|
||||
Cloudflare имеет лимиты на одновременное количество подключений WS. Домен по умолчанию может перестать работать в любой момент.
|
||||
|
||||
## Настройка своего домена
|
||||
1. Добавьте свой домен в Cloudflare (либо купив у них напрямую, либо поменяв NS сервера: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/)
|
||||
|
||||
2. В `SSL/TLS` -> `Overview` выставьте режим **Flexible**
|
||||
|
||||
3. В `DNS` -> `Records` добавьте следующие `A` записи через `+ Add Record`:
|
||||
- Name=`kws1` IPv4=`149.154.175.50`
|
||||
- Name=`kws2` IPv4=`149.154.167.51`
|
||||
- Name=`kws3` IPv4=`149.154.175.100`
|
||||
- Name=`kws4` IPv4=`149.154.167.91`
|
||||
- Name=`kws5` IPv4=`149.154.171.5`
|
||||
- Name=`kws203` IPv4=`149.154.175.50`
|
||||
|
||||
4. **Добавьте домен в [zapret](https://github.com/Flowseal/zapret-discord-youtube/) или другой софт для обхода блокировок, так как подсеть Cloudflare забанена (по крайней мере, если вы из России)**
|
||||
|
||||
5. В настройках TgWsProxy поменяйте домен на свой
|
||||
|
||||
## Mentions
|
||||
Idea - https://github.com/Nekogram/WSProxy
|
||||
Thanks to [@UjuiUjuMandan](https://github.com/UjuiUjuMandan) for the information
|
||||
@@ -1,3 +1,11 @@
|
||||
> [!TIP]
|
||||
>
|
||||
> ### 🎉 Поддержать меня
|
||||
>
|
||||
> USDT (TRC20): `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
|
||||
> BTC: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
|
||||
> ETH: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> ### Реакция антивирусов
|
||||
@@ -26,7 +34,7 @@ Telegram Desktop → MTProto Proxy (127.0.0.1:1443) → WebSocket → Telegram D
|
||||
2. Перехватывает подключения к IP-адресам Telegram
|
||||
3. Извлекает DC ID из MTProto obfuscation init-пакета
|
||||
4. Устанавливает WebSocket (TLS) соединение к соответствующему DC через домены Telegram
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на прямое TCP-соединение
|
||||
5. Если WS недоступен (302 redirect) — автоматически переключается на CfProxy / прямое TCP-соединение
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
@@ -69,8 +77,9 @@ makepkg -si
|
||||
# При помощи AUR-helper
|
||||
paru -S tg-ws-proxy-bin
|
||||
|
||||
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта прокси:
|
||||
sudo systemctl start tg-ws-proxy-cli@8888
|
||||
# Если вы установили -cli пакет, то запуск осуществляется через systemctl, где 8888 это номер порта,
|
||||
# разделитель ":" и secret, который можно сгенерировать командой: openssl rand -hex 16
|
||||
sudo systemctl start tg-ws-proxy-cli@8888:3075abe65830f0325116bb0416cadf9f
|
||||
```
|
||||
|
||||
Для остальных дистрибутивов можно использовать **`TgWsProxy_linux_amd64`** (бинарный файл для x86_64).
|
||||
@@ -128,11 +137,14 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
| `--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 (можно указать несколько раз) |
|
||||
| `--buf-kb` | `256` | Размер буфера в КБ
|
||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC
|
||||
| `--log-file` | выкл. | Путь до файла, в который сохранять логи
|
||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись)
|
||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи
|
||||
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare]((https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md)) |
|
||||
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudfalre. [Подробнее тут](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md) |
|
||||
| `--cfproxy-priority` | `true` | Пробовать проксировать через Cloudflare перед прямым TCP подключением |
|
||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||
| `--pool-size` | `4` | Количество заготовленных соединений на каждый DC |
|
||||
| `--log-file` | выкл. | Путь до файла, в который сохранять логи |
|
||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после идёт перезапись) |
|
||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||
|
||||
**Примеры:**
|
||||
24
macos.py
24
macos.py
@@ -392,6 +392,27 @@ def _edit_config_dialog() -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cfproxy = _ask_yes_no_close("Включить Cloudflare Proxy (CfProxy)?")
|
||||
if cfproxy is None:
|
||||
return
|
||||
|
||||
cfproxy_priority = True
|
||||
if cfproxy:
|
||||
cfproxy_priority_result = _ask_yes_no_close("Приоритет CfProxy (пробовать раньше прямого TCP)?")
|
||||
if cfproxy_priority_result is None:
|
||||
return
|
||||
cfproxy_priority = cfproxy_priority_result
|
||||
|
||||
cfproxy_domain = _osascript_input(
|
||||
"Домен CF-прокси:\n"
|
||||
"DNS записи kws1-kws5,kws203 должны указывать на IP датацентров Telegram через Cloudflare.\n"
|
||||
"pclead.co.uk готовый настроенный домен. Подробнее про настройку читайте в репозитории - docs/CfProxy.md",
|
||||
cfg.get("cfproxy_domain", DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk")),
|
||||
)
|
||||
if cfproxy_domain is None:
|
||||
return
|
||||
cfproxy_domain = cfproxy_domain.strip()
|
||||
|
||||
new_cfg = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
@@ -402,6 +423,9 @@ def _edit_config_dialog() -> None:
|
||||
"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"])),
|
||||
"check_updates": cfg.get("check_updates", True),
|
||||
"cfproxy": cfproxy,
|
||||
"cfproxy_priority": cfproxy_priority,
|
||||
"cfproxy_domain": cfproxy_domain or DEFAULT_CONFIG.get("cfproxy_domain", "pclead.co.uk"),
|
||||
}
|
||||
save_config(new_cfg)
|
||||
log.info("Config saved: %s", new_cfg)
|
||||
|
||||
36
packaging/version_info.txt
Normal file
36
packaging/version_info.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
# UTF-8
|
||||
#
|
||||
# For more details about fixed file info 'ffi' see:
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 0, 0, 0),
|
||||
prodvers=(1, 0, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
u'040904B0',
|
||||
[
|
||||
StringStruct(u'CompanyName', u'Flowseal'),
|
||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||
StringStruct(u'FileVersion', u'1.0.0.0'),
|
||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||
StringStruct(u'ProductVersion', u'1.0.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
@@ -34,6 +34,7 @@ a = Analysis(
|
||||
)
|
||||
|
||||
icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico')
|
||||
version_path = os.path.join(os.path.dirname(SPEC), 'version_info.txt')
|
||||
if os.path.exists(icon_path):
|
||||
a.datas += [('icon.ico', icon_path, 'DATA')]
|
||||
|
||||
@@ -50,7 +51,7 @@ exe = EXE(
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
@@ -60,4 +61,5 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=icon_path if os.path.exists(icon_path) else None,
|
||||
version=version_path if os.path.exists(version_path) else None,
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.4.0"
|
||||
__version__ = "1.5.1"
|
||||
@@ -26,9 +26,11 @@ class ProxyConfig:
|
||||
host: str = '127.0.0.1'
|
||||
secret: str = field(default_factory=lambda: os.urandom(16).hex())
|
||||
dc_redirects: Dict[int, str] = field(default_factory=lambda: {2: '149.154.167.220', 4: '149.154.167.220'})
|
||||
dc_overrides: Dict[int, int] = field(default_factory=lambda: {203: 2})
|
||||
buffer_size: int = 256 * 1024
|
||||
pool_size: int = 4
|
||||
fallback_cfproxy: bool = True
|
||||
fallback_cfproxy_priority: bool = True
|
||||
fallback_cfproxy_domain: str = 'pclead.co.uk'
|
||||
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
@@ -40,7 +42,7 @@ DC_DEFAULT_IPS: Dict[int, str] = {
|
||||
3: '149.154.175.100',
|
||||
4: '149.154.167.91',
|
||||
5: '149.154.171.5',
|
||||
203: '91.105.192.100'
|
||||
203: '149.154.175.50'
|
||||
}
|
||||
|
||||
HANDSHAKE_LEN = 64
|
||||
@@ -475,7 +477,8 @@ class _MsgSplitter:
|
||||
|
||||
|
||||
def _ws_domains(dc: int, is_media) -> List[str]:
|
||||
dc = proxy_config.dc_overrides.get(dc, dc)
|
||||
if dc == 203:
|
||||
dc = 2
|
||||
if is_media is None or is_media:
|
||||
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
|
||||
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
|
||||
@@ -487,6 +490,7 @@ class Stats:
|
||||
self.connections_active = 0
|
||||
self.connections_ws = 0
|
||||
self.connections_tcp_fallback = 0
|
||||
self.connections_cfproxy = 0
|
||||
self.connections_bad = 0
|
||||
self.ws_errors = 0
|
||||
self.bytes_up = 0
|
||||
@@ -502,6 +506,7 @@ class Stats:
|
||||
f"active={self.connections_active} "
|
||||
f"ws={self.connections_ws} "
|
||||
f"tcp_fb={self.connections_tcp_fallback} "
|
||||
f"cf={self.connections_cfproxy} "
|
||||
f"bad={self.connections_bad} "
|
||||
f"err={self.ws_errors} "
|
||||
f"pool={pool_s} "
|
||||
@@ -603,6 +608,10 @@ class _WsPool:
|
||||
self._schedule_refill((dc, is_media), target_ip, domains)
|
||||
log.info("WS pool warmup started for %d DC(s)", len(dc_redirects))
|
||||
|
||||
def reset(self):
|
||||
self._idle.clear()
|
||||
self._refilling.clear()
|
||||
|
||||
_ws_pool = _WsPool()
|
||||
|
||||
|
||||
@@ -784,6 +793,88 @@ def _fallback_ip(dc: int) -> Optional[str]:
|
||||
return DC_DEFAULT_IPS.get(dc)
|
||||
|
||||
|
||||
def _cfproxy_domains(dc: int) -> List[str]:
|
||||
base = proxy_config.fallback_cfproxy_domain
|
||||
return [f'kws{dc}.{base}']
|
||||
|
||||
|
||||
async def _cfproxy_fallback(reader, writer, relay_init, label,
|
||||
dc=None, is_media=False,
|
||||
clt_decryptor=None, clt_encryptor=None,
|
||||
tg_encryptor=None, tg_decryptor=None,
|
||||
splitter=None):
|
||||
domains = _cfproxy_domains(dc)
|
||||
media_tag = ' media' if is_media else ''
|
||||
ws = None
|
||||
for domain in domains:
|
||||
log.info("[%s] DC%d%s -> CF proxy wss://%s/apiws",
|
||||
label, dc, media_tag, domain)
|
||||
try:
|
||||
ws = await RawWebSocket.connect(domain, domain,
|
||||
timeout=10.0)
|
||||
break
|
||||
except Exception as exc:
|
||||
log.warning("[%s] DC%d%s CF proxy %s failed: %s",
|
||||
label, dc, media_tag, domain, exc)
|
||||
if ws is None:
|
||||
return False
|
||||
_stats.connections_cfproxy += 1
|
||||
await ws.send(relay_init)
|
||||
await _bridge_ws_reencrypt(reader, writer, ws, label,
|
||||
dc=dc, is_media=is_media,
|
||||
clt_decryptor=clt_decryptor,
|
||||
clt_encryptor=clt_encryptor,
|
||||
tg_encryptor=tg_encryptor,
|
||||
tg_decryptor=tg_decryptor,
|
||||
splitter=splitter)
|
||||
return True
|
||||
|
||||
|
||||
async def _do_fallback(reader, writer, relay_init, label,
|
||||
dc, is_media, media_tag,
|
||||
clt_decryptor, clt_encryptor,
|
||||
tg_encryptor, tg_decryptor,
|
||||
splitter=None):
|
||||
"""Try CF proxy and/or TCP fallback based on config priority."""
|
||||
fallback_dst = _fallback_ip(dc)
|
||||
use_cf = proxy_config.fallback_cfproxy
|
||||
cf_first = proxy_config.fallback_cfproxy_priority
|
||||
|
||||
methods: List[str] = []
|
||||
if use_cf and cf_first:
|
||||
methods = ['cf', 'tcp']
|
||||
elif use_cf:
|
||||
methods = ['tcp', 'cf']
|
||||
else:
|
||||
methods = ['tcp']
|
||||
|
||||
for method in methods:
|
||||
if method == 'cf':
|
||||
ok = await _cfproxy_fallback(
|
||||
reader, writer, relay_init, label,
|
||||
dc=dc, is_media=is_media,
|
||||
clt_decryptor=clt_decryptor,
|
||||
clt_encryptor=clt_encryptor,
|
||||
tg_encryptor=tg_encryptor,
|
||||
tg_decryptor=tg_decryptor,
|
||||
splitter=splitter)
|
||||
if ok:
|
||||
return True
|
||||
elif method == 'tcp' and fallback_dst:
|
||||
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
ok = await _tcp_fallback(
|
||||
reader, writer, fallback_dst, 443,
|
||||
relay_init, label, dc=dc, is_media=is_media,
|
||||
clt_decryptor=clt_decryptor,
|
||||
clt_encryptor=clt_encryptor,
|
||||
tg_encryptor=tg_encryptor,
|
||||
tg_decryptor=tg_decryptor)
|
||||
if ok:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _handle_client(reader, writer, secret: bytes):
|
||||
_stats.connections_total += 1
|
||||
_stats.connections_active += 1
|
||||
@@ -867,27 +958,29 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
|
||||
tg_encryptor.update(ZERO_64)
|
||||
|
||||
dc_key = (dc, is_media)
|
||||
dc_key = f'{dc}{"m" if is_media else ""}'
|
||||
media_tag = " media" if is_media else ""
|
||||
|
||||
# Fallback if DC not in config or WS blacklisted for this DC/is_media
|
||||
if dc not in proxy_config.dc_redirects or dc_key in ws_blacklist:
|
||||
fallback_dst = _fallback_ip(dc)
|
||||
if fallback_dst:
|
||||
if dc not in proxy_config.dc_redirects:
|
||||
log.info("[%s] DC%d not in config -> TCP fallback %s:443",
|
||||
label, dc, fallback_dst)
|
||||
else:
|
||||
log.info("[%s] DC%d%s WS blacklisted -> TCP fallback %s:443",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
await _tcp_fallback(reader, writer, fallback_dst, 443,
|
||||
relay_init, label, dc=dc,
|
||||
is_media=is_media,
|
||||
clt_decryptor=clt_decryptor,
|
||||
clt_encryptor=clt_encryptor,
|
||||
tg_encryptor=tg_encryptor,
|
||||
tg_decryptor=tg_decryptor)
|
||||
log.info("[%s] DC%d not in config -> fallback",
|
||||
label, dc)
|
||||
else:
|
||||
log.info("[%s] DC%d%s WS blacklisted -> fallback",
|
||||
label, dc, media_tag)
|
||||
splitter = None
|
||||
try:
|
||||
splitter = _MsgSplitter(relay_init, proto_int)
|
||||
except Exception:
|
||||
pass
|
||||
ok = await _do_fallback(
|
||||
reader, writer, relay_init, label,
|
||||
dc, is_media, media_tag,
|
||||
clt_decryptor, clt_encryptor,
|
||||
tg_encryptor, tg_decryptor,
|
||||
splitter=splitter)
|
||||
if not ok:
|
||||
log.warning("[%s] DC%d%s no fallback available",
|
||||
label, dc, media_tag)
|
||||
return
|
||||
@@ -948,18 +1041,19 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
log.info("[%s] DC%d%s WS cooldown for %ds",
|
||||
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
|
||||
|
||||
fallback_dst = _fallback_ip(dc) or target
|
||||
log.info("[%s] DC%d%s -> TCP fallback to %s:443",
|
||||
label, dc, media_tag, fallback_dst)
|
||||
ok = await _tcp_fallback(reader, writer, fallback_dst, 443,
|
||||
relay_init, label, dc=dc,
|
||||
is_media=is_media,
|
||||
clt_decryptor=clt_decryptor,
|
||||
clt_encryptor=clt_encryptor,
|
||||
tg_encryptor=tg_encryptor,
|
||||
tg_decryptor=tg_decryptor)
|
||||
splitter_fb = None
|
||||
try:
|
||||
splitter_fb = _MsgSplitter(relay_init, proto_int)
|
||||
except Exception:
|
||||
pass
|
||||
ok = await _do_fallback(
|
||||
reader, writer, relay_init, label,
|
||||
dc, is_media, media_tag,
|
||||
clt_decryptor, clt_encryptor,
|
||||
tg_encryptor, tg_decryptor,
|
||||
splitter=splitter_fb)
|
||||
if ok:
|
||||
log.info("[%s] DC%d%s TCP fallback closed",
|
||||
log.info("[%s] DC%d%s fallback closed",
|
||||
label, dc, media_tag)
|
||||
return
|
||||
|
||||
@@ -1015,6 +1109,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
global _server_instance, _server_stop_event
|
||||
_server_stop_event = stop_event
|
||||
|
||||
_ws_pool.reset()
|
||||
ws_blacklist.clear()
|
||||
dc_fail_until.clear()
|
||||
|
||||
secret_bytes = bytes.fromhex(proxy_config.secret)
|
||||
|
||||
def client_cb(r, w):
|
||||
@@ -1040,6 +1138,10 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
for dc in sorted(proxy_config.dc_redirects.keys()):
|
||||
ip = proxy_config.dc_redirects.get(dc)
|
||||
log.info(" DC%d: %s", dc, ip)
|
||||
if proxy_config.fallback_cfproxy:
|
||||
prio = 'CF first' if proxy_config.fallback_cfproxy_priority else 'TCP first'
|
||||
log.info(" CF proxy: %s (%s)",
|
||||
proxy_config.fallback_cfproxy_domain, prio)
|
||||
log.info("=" * 60)
|
||||
log.info(" Connect link:")
|
||||
log.info(" %s", tg_link)
|
||||
@@ -1119,7 +1221,7 @@ def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description='Telegram MTProto WebSocket Bridge Proxy')
|
||||
ap.add_argument('--port', type=int, default=1443,
|
||||
help=f'Listen port (default 1443)')
|
||||
help='Listen port (default 1443)')
|
||||
ap.add_argument('--host', type=str, default='127.0.0.1',
|
||||
help='Listen host (default 127.0.0.1)')
|
||||
ap.add_argument('--secret', type=str, default=None,
|
||||
@@ -1139,6 +1241,14 @@ def main():
|
||||
help='Socket send/recv buffer size in KB (default 256)')
|
||||
ap.add_argument('--pool-size', type=int, default=4, metavar='N',
|
||||
help='WS connection pool size per DC (default 4, min 0)')
|
||||
ap.add_argument('--cfproxy-domain', type=str, default='pclead.co.uk',
|
||||
metavar='DOMAIN',
|
||||
help='Cloudflare-proxied domain for WS fallback '
|
||||
'(default: pclead.co.uk)')
|
||||
ap.add_argument('--no-cfproxy', action='store_true',
|
||||
help='Disable Cloudflare proxy fallback')
|
||||
ap.add_argument('--cfproxy-priority', type=bool, default=True,
|
||||
help='Try cfproxy before tcp fallback (default: true)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.dc_ip:
|
||||
@@ -1171,7 +1281,10 @@ def main():
|
||||
secret=secret_hex,
|
||||
dc_redirects=dc_redirects,
|
||||
buffer_size=max(4, args.buf_kb) * 1024,
|
||||
pool_size=max(0, args.pool_size)
|
||||
pool_size=max(0, args.pool_size),
|
||||
fallback_cfproxy=not args.no_cfproxy,
|
||||
fallback_cfproxy_priority=args.cfproxy_priority,
|
||||
fallback_cfproxy_domain=args.cfproxy_domain,
|
||||
)
|
||||
|
||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||
|
||||
@@ -7,7 +7,7 @@ name = "tg-ws-proxy"
|
||||
dynamic=["version"]
|
||||
|
||||
description = "Telegram Desktop WebSocket Bridge Proxy"
|
||||
readme = "README.md"
|
||||
readme = "docs/README.md"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
license = { name = "MIT", file = "LICENSE" }
|
||||
|
||||
@@ -27,8 +27,9 @@ _TIP_PORT = (
|
||||
_TIP_SECRET = "Секретный ключ для авторизации клиентов"
|
||||
_TIP_DC = (
|
||||
"Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n"
|
||||
"Каждая строка: «номер:IP», например 2:149.154.167.220. "
|
||||
"Прокси по этим правилам направляет трафик к нужным серверам Telegram"
|
||||
"Каждая строка: «номер:IP», например 4:149.154.167.220. "
|
||||
"Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n"
|
||||
"Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220"
|
||||
)
|
||||
_TIP_VERBOSE = (
|
||||
"Если включено, в файл логов пишется больше подробностей — "
|
||||
@@ -50,9 +51,100 @@ _TIP_AUTOSTART = (
|
||||
"Если вы переместите программу в другую папку, автозапуск сбросится"
|
||||
)
|
||||
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
|
||||
_TIP_CFPROXY = (
|
||||
"Использовать Cloudflare прокси для недоступных датацентров"
|
||||
)
|
||||
_TIP_CFPROXY_PRIORITY = (
|
||||
"Пробовать CF-прокси раньше прямого TCP-подключения"
|
||||
)
|
||||
_TIP_CFPROXY_DOMAIN = (
|
||||
"Домен, проксируемый через Cloudflare, для WS-подключения"
|
||||
)
|
||||
_TIP_SAVE = "Сохранить настройки"
|
||||
_TIP_CANCEL = "Закрыть окно без сохранения изменений"
|
||||
|
||||
_CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md"
|
||||
_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203]
|
||||
|
||||
|
||||
def _run_cfproxy_connectivity_test(domain: str) -> dict:
|
||||
import base64
|
||||
import ssl
|
||||
import socket as _socket
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
results = {}
|
||||
for dc in _CFPROXY_TEST_DCS:
|
||||
host = f"kws{dc}.{domain}"
|
||||
try:
|
||||
with _socket.create_connection((host, 443), timeout=5) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname=host) as ssock:
|
||||
ws_key = base64.b64encode(os.urandom(16)).decode()
|
||||
req = (
|
||||
f"GET /apiws HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
f"Upgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
f"Sec-WebSocket-Version: 13\r\n"
|
||||
f"Sec-WebSocket-Protocol: binary\r\n"
|
||||
f"\r\n"
|
||||
).encode()
|
||||
ssock.sendall(req)
|
||||
ssock.settimeout(5)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = ssock.recv(512)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
first = buf.decode("utf-8", errors="replace").split("\r\n")[0]
|
||||
if "101" in first:
|
||||
results[dc] = True
|
||||
else:
|
||||
results[dc] = first or "нет ответа"
|
||||
ssock.close()
|
||||
raw.close()
|
||||
except _socket.timeout:
|
||||
results[dc] = "таймаут"
|
||||
except OSError as exc:
|
||||
msg = str(exc)
|
||||
results[dc] = msg[:60] if len(msg) > 60 else msg
|
||||
return results
|
||||
|
||||
|
||||
def _cfproxy_show_test_results(domain: str, results: dict) -> None:
|
||||
import tkinter as _tk
|
||||
from tkinter import messagebox as _mb
|
||||
|
||||
ok = [dc for dc, v in results.items() if v is True]
|
||||
fail = [(dc, v) for dc, v in results.items() if v is not True]
|
||||
if len(ok) == len(_CFPROXY_TEST_DCS):
|
||||
title = "CF-прокси: всё работает"
|
||||
msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}."
|
||||
elif not ok:
|
||||
title = "CF-прокси: недоступен"
|
||||
msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n"
|
||||
msg += "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
||||
else:
|
||||
title = "CF-прокси: частично работает"
|
||||
msg = (
|
||||
f"Домен: {domain}\n\n"
|
||||
f"\u2713 Работают: {', '.join(f'kws{dc}' for dc in ok)}\n\n"
|
||||
f"\u2717 Недоступны:\n"
|
||||
+ "\n".join(f" kws{dc}: {v}" for dc, v in fail)
|
||||
)
|
||||
root = _tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
_mb.showinfo(title, msg, parent=root)
|
||||
root.destroy()
|
||||
|
||||
_INNER_W = 396
|
||||
|
||||
|
||||
@@ -155,6 +247,9 @@ class TrayConfigFormWidgets:
|
||||
adv_keys: Tuple[str, ...]
|
||||
autostart_var: Optional[Any]
|
||||
check_updates_var: Optional[Any]
|
||||
cfproxy_var: Optional[Any] = None
|
||||
cfproxy_priority_var: Optional[Any] = None
|
||||
cfproxy_domain_var: Optional[Any] = None
|
||||
|
||||
|
||||
def install_tray_config_form(
|
||||
@@ -233,6 +328,76 @@ def install_tray_config_form(
|
||||
dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"])))
|
||||
attach_tooltip_to_widgets([dc_lbl, dc_textbox], _TIP_DC)
|
||||
|
||||
cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy")
|
||||
|
||||
cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_row.pack(fill="x", pady=(0, 6))
|
||||
|
||||
cfproxy_var = ctk.BooleanVar(
|
||||
value=cfg.get("cfproxy", default_config.get("cfproxy", True))
|
||||
)
|
||||
cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var)
|
||||
cf_cb.pack(side="left", padx=(0, 16))
|
||||
attach_ctk_tooltip(cf_cb, _TIP_CFPROXY)
|
||||
|
||||
cfproxy_priority_var = ctk.BooleanVar(
|
||||
value=cfg.get("cfproxy_priority", default_config.get("cfproxy_priority", True))
|
||||
)
|
||||
cf_prio_cb = _checkbox(ctk, cf_row, theme, "Приоритет CF-прокси", cfproxy_priority_var)
|
||||
cf_prio_cb.pack(side="left")
|
||||
attach_ctk_tooltip(cf_prio_cb, _TIP_CFPROXY_PRIORITY)
|
||||
|
||||
cf_domain_row = ctk.CTkFrame(cf_inner, fg_color="transparent")
|
||||
cf_domain_row.pack(fill="x")
|
||||
|
||||
cf_domain_col, cfproxy_domain_var = _labeled_entry(
|
||||
ctk, cf_domain_row, theme, "Домен",
|
||||
cfg.get("cfproxy_domain", default_config.get("cfproxy_domain", "pclead.co.uk")),
|
||||
tip=_TIP_CFPROXY_DOMAIN, width=160, pack_fill=True,
|
||||
)
|
||||
cf_domain_col.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||
|
||||
_cf_test_btn = [None]
|
||||
|
||||
def _on_cf_test():
|
||||
domain = cfproxy_domain_var.get().strip()
|
||||
if not domain:
|
||||
return
|
||||
btn = _cf_test_btn[0]
|
||||
if btn:
|
||||
btn.configure(text="...", state="disabled")
|
||||
import threading as _threading
|
||||
def _worker():
|
||||
res = _run_cfproxy_connectivity_test(domain)
|
||||
if btn:
|
||||
btn.after(0, lambda: btn.configure(text="Тест", state="normal"))
|
||||
btn.after(0, lambda: _cfproxy_show_test_results(domain, res))
|
||||
_threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
cf_test_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
||||
cf_test_col.pack(side="left", anchor="s", padx=(0, 6))
|
||||
ctk.CTkLabel(cf_test_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
||||
_cf_test_widget = ctk.CTkButton(
|
||||
cf_test_col, text="Тест", width=56, height=36,
|
||||
font=(theme.ui_font_family, 13), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=_on_cf_test,
|
||||
)
|
||||
_cf_test_widget.pack()
|
||||
_cf_test_btn[0] = _cf_test_widget
|
||||
|
||||
cf_help_col = ctk.CTkFrame(cf_domain_row, fg_color="transparent")
|
||||
cf_help_col.pack(side="left", anchor="s")
|
||||
ctk.CTkLabel(cf_help_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2))
|
||||
ctk.CTkButton(
|
||||
cf_help_col, text="?", width=36, height=36,
|
||||
font=(theme.ui_font_family, 18), corner_radius=10,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", border_width=1, border_color=theme.field_border,
|
||||
command=lambda: webbrowser.open(_CFPROXY_HELP_URL),
|
||||
).pack()
|
||||
|
||||
log_inner = _config_section(ctk, frame, theme, "Логи и производительность")
|
||||
|
||||
verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False))
|
||||
@@ -321,6 +486,9 @@ def install_tray_config_form(
|
||||
dc_textbox=dc_textbox, verbose_var=verbose_var,
|
||||
adv_entries=adv_entries, adv_keys=adv_keys,
|
||||
autostart_var=autostart_var, check_updates_var=check_updates_var,
|
||||
cfproxy_var=cfproxy_var,
|
||||
cfproxy_priority_var=cfproxy_priority_var,
|
||||
cfproxy_domain_var=cfproxy_domain_var,
|
||||
)
|
||||
|
||||
|
||||
@@ -363,9 +531,9 @@ def validate_config_form(
|
||||
return "Порт должен быть числом 1-65535"
|
||||
|
||||
lines = [
|
||||
l.strip()
|
||||
for l in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
||||
if l.strip()
|
||||
line.strip()
|
||||
for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
try:
|
||||
tg_ws_proxy.parse_dc_ip_list(lines)
|
||||
@@ -397,6 +565,14 @@ def validate_config_form(
|
||||
merge_adv_from_form(widgets, new_cfg, default_config)
|
||||
if widgets.check_updates_var is not None:
|
||||
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
|
||||
if widgets.cfproxy_var is not None:
|
||||
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
|
||||
if widgets.cfproxy_priority_var is not None:
|
||||
new_cfg["cfproxy_priority"] = bool(widgets.cfproxy_priority_var.get())
|
||||
if widgets.cfproxy_domain_var is not None:
|
||||
domain = widgets.cfproxy_domain_var.get().strip()
|
||||
if domain:
|
||||
new_cfg["cfproxy_domain"] = domain
|
||||
return new_cfg
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
|
||||
"log_max_mb": 5,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"cfproxy": True,
|
||||
"cfproxy_priority": True,
|
||||
"cfproxy_domain": "pclead.co.uk",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,12 +60,6 @@ def _same_process(meta: dict, proc: psutil.Process, script_hint: str) -> bool:
|
||||
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
|
||||
|
||||
|
||||
@@ -76,7 +70,10 @@ def acquire_lock(script_hint: str = "") -> bool:
|
||||
try:
|
||||
pid = int(f.stem)
|
||||
except Exception:
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
meta: dict = {}
|
||||
try:
|
||||
@@ -85,12 +82,17 @@ def acquire_lock(script_hint: str = "") -> bool:
|
||||
meta = json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
is_running = False
|
||||
try:
|
||||
if _same_process(meta, psutil.Process(pid), script_hint):
|
||||
return False
|
||||
is_running = _same_process(meta, psutil.Process(pid), script_hint)
|
||||
except Exception:
|
||||
pass
|
||||
if is_running:
|
||||
return False
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
lock_file = APP_DIR / f"{os.getpid()}.lock"
|
||||
try:
|
||||
@@ -100,7 +102,10 @@ def acquire_lock(script_hint: str = "") -> bool:
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
lock_file.touch()
|
||||
except Exception:
|
||||
pass
|
||||
_lock_file_path = lock_file
|
||||
return True
|
||||
|
||||
@@ -264,6 +269,9 @@ def apply_proxy_config(cfg: dict) -> bool:
|
||||
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"]))
|
||||
pc.fallback_cfproxy = cfg.get("cfproxy", DEFAULT_CONFIG["cfproxy"])
|
||||
pc.fallback_cfproxy_priority = cfg.get("cfproxy_priority", DEFAULT_CONFIG["cfproxy_priority"])
|
||||
pc.fallback_cfproxy_domain = cfg.get("cfproxy_domain", DEFAULT_CONFIG["cfproxy_domain"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
35
utils/win32_theme.py
Normal file
35
utils/win32_theme.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
def is_windows_dark_theme() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
|
||||
try:
|
||||
import winreg
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
return value == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def apply_windows_dark_theme() -> None:
|
||||
try:
|
||||
import ctypes
|
||||
uxtheme = ctypes.windll.uxtheme
|
||||
|
||||
try:
|
||||
set_preferred = uxtheme[135]
|
||||
result = set_preferred(2)
|
||||
if result == 0:
|
||||
flush = uxtheme[136]
|
||||
flush()
|
||||
except Exception:
|
||||
try:
|
||||
allow_dark = uxtheme[135]
|
||||
allow_dark(True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
@@ -32,6 +32,10 @@ except ImportError:
|
||||
|
||||
import proxy.tg_ws_proxy as tg_ws_proxy
|
||||
|
||||
from utils.win32_theme import (
|
||||
is_windows_dark_theme,
|
||||
apply_windows_dark_theme,
|
||||
)
|
||||
from utils.tray_common import (
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
||||
@@ -316,6 +320,10 @@ def run_tray() -> None:
|
||||
global _tray_icon, _config
|
||||
|
||||
_config = load_config()
|
||||
|
||||
if is_windows_dark_theme:
|
||||
apply_windows_dark_theme()
|
||||
|
||||
bootstrap(_config)
|
||||
|
||||
if pystray is None or Image is None or ctk is None:
|
||||
|
||||
Reference in New Issue
Block a user