Compare commits

...

21 Commits

Author SHA1 Message Date
Flowseal 222cabeee0 Clarify naming 2026-09-19 11:32:10 +03:00
Flowseal c2da669576 Bump metadata version 2026-09-19 11:27:50 +03:00
Flowseal 2c9a0b8a90 Bump version 2026-09-19 11:26:32 +03:00
Flowseal 87977962dc Disable SSL switch 2026-09-19 11:25:12 +03:00
Flowseal 5a61d8f782 Simplify passing None to --dc-ip 2026-09-18 17:30:35 +03:00
Flowseal f200e33fd2 version bump 2026-09-07 16:32:42 +03:00
Flowseal c02398fbc0 censoring domains 2026-09-05 16:23:24 +03:00
Flowseal 5f55245184 fix linux build 2026-09-05 15:39:45 +03:00
Flowseal 312d844eea removed old contribution link 2026-09-05 00:04:15 +03:00
Flowseal be485a3851 version bump 2026-09-04 17:49:40 +03:00
Flowseal f811a10918 don't close update dialog on release page open 2026-09-04 17:36:15 +03:00
Flowseal 55ec6a91df remove topmost 2026-09-04 17:35:41 +03:00
Flowseal b23c8f6122 #1213 fixes 2026-09-04 17:22:30 +03:00
Konukhov Yaroslav d36bb7df71 feat: add manual update triggers to settings and tray menu (#1213) 2026-09-04 17:13:56 +03:00
Flowseal bd4f3d080c todo text correction 2026-09-03 20:20:41 +03:00
Flowseal 492d999248 closes #1232 2026-09-03 20:17:46 +03:00
Flowseal e8ad0d6958 refill pool after rotating expired connections 2026-09-03 17:25:21 +03:00
Flowseal 5eb7e007d3 try ws_pool if connection is timed out 2026-09-03 17:09:18 +03:00
Flowseal 1ae2f3a147 todo #1232 2026-09-03 05:14:03 +03:00
Flowseal d2b6c450eb TODOs 2026-09-03 05:11:03 +03:00
Matt Van Horn 1b8671b3d3 fix: restore Linux Mint 21 binary compatibility (#1238)
Fixes #1144
2026-09-01 18:27:13 +03:00
27 changed files with 402 additions and 136 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://nowpayments.io/donation/flowseal']
+3 -1
View File
@@ -382,7 +382,7 @@ jobs:
path: dist/TgWsProxy_macos_universal.dmg
build-linux:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }}
steps:
- name: Checkout
@@ -395,6 +395,8 @@ jobs:
python3-venv \
python3-dev \
python3-gi \
python3-gi-cairo \
gir1.2-appindicator3-0.1 \
gir1.2-ayatanaappindicator3-0.1 \
python3-tk
+4 -3
View File
@@ -51,6 +51,7 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--no-cfproxy` | `false` | Disable [Cloudflare proxying](./CfProxy.md) attempts |
| `--cfproxy-domain` | | Specify your own domain for Cloudflare proxying [Learn more](./CfProxy.md). Can be specified multiple times. |
| `--cfproxy-worker-domain` | | Cloudflare Worker domain [Learn more](./CfWorker.md). Can be specified multiple times. |
| `--no-secure` | `false` | Use 80 port for CF-proxy and CF-worker connections |
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
| `--buf-kb` | `256` | Buffer size in KB |
@@ -64,13 +65,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```bash
# Standard startup
tg-ws-proxy
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Different port and additional DCs
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# With verbose logging
tg-ws-proxy -v
# With verbose logging, without direct connections to DCs
tg-ws-proxy -v --dc-ip
# Fake TLS masquerading (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
> [!CAUTION]
>
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION]
>
+4 -3
View File
@@ -51,6 +51,7 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
| `--no-cfproxy` | `false` | Отключить попытку [проксирования через Cloudflare](./CfProxy.md) |
| `--cfproxy-domain` | | Указать свой домен для проксирования через Cloudflare [Подробнее](./CfProxy.md). Можно указать несколько через повторение аргумента. |
| `--cfproxy-worker-domain` | | Домен Cloudflare Worker [Подробнее](./CfWorker.md). Можно указать несколько через повторение аргумента. |
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
| `--buf-kb` | `256` | Размер буфера в КБ |
@@ -64,13 +65,13 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
```bash
# Стандартный запуск
tg-ws-proxy
tg-ws-proxy --secret 00112233445566778899aabbccddeeff
# Другой порт и дополнительные DC
tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220
# С подробным логированием
tg-ws-proxy -v
# С подробным логированием, без прямого подключения к DC
tg-ws-proxy -v --dc-ip
# Fake TLS маскировка (ee-secret)
tg-ws-proxy --fake-tls-domain example.com
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
+43 -3
View File
@@ -147,8 +147,8 @@ def _edit_config_dialog() -> None:
theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=_apply_window_icon,
ctk, title=t("app.settings_title"), width=w, height=h,
theme=theme, topmost=False, after_create=_apply_window_icon
)
fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -244,7 +244,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=_apply_window_icon,
topmost=False, after_create=_apply_window_icon,
)
def on_done(open_tg: bool) -> None:
@@ -310,7 +310,47 @@ def run_tray() -> None:
log.info("Tray app exited")
def _smoke_test() -> None:
"""Exercise the frozen GUI stack without starting the proxy or writing config."""
import gi
gi.require_version('AppIndicator3', '0.1')
gi.require_version('AyatanaAppIndicator3', '0.1')
from gi.repository import AppIndicator3, AyatanaAppIndicator3
# Namespace imports alone do not resolve app_indicator_new: call it too.
indicators = [
namespace.Indicator.new(
'tg-ws-proxy-smoke-test', '', namespace.IndicatorCategory.APPLICATION_STATUS,
)
for namespace in (AppIndicator3, AyatanaAppIndicator3)
]
icon = pystray.Icon('tg-ws-proxy-smoke-test', Image.new('RGB', (16, 16)))
root = ctk.CTk()
root.withdraw()
root.update_idletasks()
root.destroy()
# A successful constructor on the build host can hide missing bundled
# libraries. Check where the dynamic loader actually obtained them.
bundle_dir = os.path.realpath(sys._MEIPASS) + os.sep
prefixes = ('libglib-', 'libgobject-', 'libgio-', 'libgtk-',
'libappindicator', 'libayatana-', 'libdbusmenu-')
with open('/proc/self/maps', encoding='utf-8') as maps:
paths = {line.split(maxsplit=5)[-1].strip() for line in maps}
for path in sorted(paths):
if os.path.basename(path).startswith(prefixes):
if not os.path.realpath(path).startswith(bundle_dir):
raise RuntimeError('GUI library loaded outside the bundle: ' + path)
print(path)
assert icon is not None and all(indicators)
print('Linux bundle smoke test passed')
def main() -> None:
if sys.argv[1:] == ['--smoke-test']:
_smoke_test()
return
if not acquire_lock():
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return
+2
View File
@@ -348,6 +348,7 @@ def _edit_config_dialog() -> None:
width=width,
height=height,
theme=theme,
topmost=False,
after_create=lambda window: _activate_app(),
)
_settings_window = root
@@ -471,6 +472,7 @@ def _show_first_run() -> None:
width=width,
height=height,
theme=theme,
topmost=False,
after_create=lambda window: _activate_app(),
)
+40 -16
View File
@@ -1,10 +1,9 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
import os
import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
from PyInstaller.utils.hooks import collect_data_files
block_cipher = None
@@ -15,21 +14,18 @@ certifi_datas = collect_data_files('certifi')
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
# Collect gi (PyGObject) submodules and data so pystray._appindicator works
gi_hiddenimports = collect_submodules('gi')
gi_datas = collect_data_files('gi')
# Collect GObject typelib files from the system
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0')
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
appindicator_binaries = [
(path, '.')
for pattern in ('/usr/lib/*/libappindicator3.so.1',
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
for path in glob.glob(pattern)
]
a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[],
binaries=[],
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas,
binaries=appindicator_binaries,
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[
'pystray._appindicator',
'PIL._tkinter_finder',
@@ -39,15 +35,22 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl',
'gi',
'_gi',
'gi.repository.GLib',
'gi.repository.GObject',
'gi.repository.Gtk',
'gi.repository.Gdk',
'gi.repository.DBus',
'gi.repository.AppIndicator3',
'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports,
],
hookspath=[],
hooksconfig={},
hooksconfig={
'gi': {
'icons': [],
'themes': [],
'languages': ['en', 'ru'],
},
},
runtime_hooks=[],
excludes=[
'PIL._avif',
@@ -58,6 +61,27 @@ a = Analysis(
cipher=block_cipher,
)
_required_libraries = {
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
'libgtk-3.so.0', 'libappindicator3.so.1',
'libayatana-appindicator3.so.1',
}
_required_typelibs = {
'AppIndicator3-0.1.typelib', 'AyatanaAppIndicator3-0.1.typelib', 'DBus-1.0.typelib',
}
_bundled_libraries = {
os.path.basename(name)
for name, _, kind in a.binaries + a.datas
if kind in ('BINARY', 'SYMLINK')
}
_missing = (
_required_libraries - _bundled_libraries
) | (
_required_typelibs - {os.path.basename(name) for name, _, _ in a.datas}
)
if _missing:
raise RuntimeError('Incomplete Linux GI bundle: ' + ', '.join(sorted(_missing)))
_PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin',
+4 -4
View File
@@ -4,8 +4,8 @@
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 10, 0, 0),
prodvers=(1, 10, 0, 0),
filevers=(1, 10, 3, 0),
prodvers=(1, 10, 3, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -21,12 +21,12 @@ VSVersionInfo(
[
StringStruct(u'CompanyName', u'Flowseal'),
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
StringStruct(u'FileVersion', u'1.10.0.0'),
StringStruct(u'FileVersion', u'1.10.3.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.10.0.0'),
StringStruct(u'ProductVersion', u'1.10.3.0'),
]
)
]
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener
__version__ = "1.10.0"
__version__ = "1.10.3"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+4 -2
View File
@@ -201,7 +201,8 @@ async def _cfproxy_worker_fallback(reader, writer, relay_init, label,
try:
ws = await RawWebSocket.connect(worker_domain, worker_domain,
timeout=10.0, path=path)
timeout=10.0, path=path,
secure=not proxy_config.disable_secure)
break
except Exception as exc:
cf_worker_pool.report_failure(worker_domain, exc)
@@ -234,7 +235,8 @@ async def _cfproxy_fallback(reader, writer, relay_init, label,
for base_domain in balancer.get_domains_for_dc(dc):
domain = f'kws{dc}.{base_domain}'
try:
ws = await RawWebSocket.connect(domain, domain, timeout=10.0)
ws = await RawWebSocket.connect(domain, domain, timeout=10.0,
secure=not proxy_config.disable_secure)
chosen_domain = base_domain
break
except Exception as exc:
+1
View File
@@ -70,6 +70,7 @@ class ProxyConfig:
fallback_cfproxy: bool = True
cfproxy_user_domains: List[str] = field(default_factory=list)
cfproxy_worker_domains: List[str] = field(default_factory=list)
disable_secure: bool = False
fake_tls_domain: str = ''
proxy_protocol: bool = False
force_test_dc: bool = False
+13 -11
View File
@@ -14,10 +14,12 @@ from .utils import ws_domains, DC_DEFAULT_IPS
log = logging.getLogger('tg-mtproto-proxy')
# TODO: domains handling is broken: wrong is_media flag causes tcp_reset after handshake,
# but initial connection is still established no matter what is is_media flag is set to
class _WsPool:
WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 60.0
REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0
def __init__(self):
@@ -26,11 +28,10 @@ class _WsPool:
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {}
self.try_fronting_first = False
self.try_fronting_first = True
async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str],
*, allow_refill: bool = True
target_ip: str, domains: List[str]
) -> Optional[RawWebSocket]:
key = (dc, is_media)
now = time.monotonic()
@@ -50,13 +51,11 @@ class _WsPool:
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket))
self.report_success(dc, is_media)
if allow_refill:
self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return ws
stats.pool_misses += 1
if allow_refill:
self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return None
def _schedule_refill(self, key, target_ip, domains):
@@ -98,7 +97,7 @@ class _WsPool:
self._refill_failures[key] = failures
delay = min(
self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 6)),
* (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX,
)
self._refill_after[key] = time.monotonic() + delay
@@ -150,6 +149,8 @@ class _WsPool:
log.debug(
"WS pool rotated DC%d%s: %d stale, %d ready",
dc, 'm' if is_media else '', len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains)
finally:
if self._rotating.get(key) is asyncio.current_task():
@@ -166,7 +167,7 @@ class _WsPool:
target_ip, domain, timeout=8)
self.try_fronting_first = False
return ws
except asyncio.TimeoutError:
except (asyncio.TimeoutError, ConnectionResetError):
if self.try_fronting_first:
return None
return await self._connect_fronted(target_ip, domain)
@@ -288,7 +289,8 @@ class _CfWorkerPool:
for worker_domain in self.available_domains(worker_domains):
try:
ws = await RawWebSocket.connect(
worker_domain, worker_domain, timeout=8, path=path)
worker_domain, worker_domain, timeout=8, path=path,
secure=not proxy_config.disable_secure)
return ws, worker_domain
except Exception as exc:
self.report_failure(worker_domain, exc)
+17 -7
View File
@@ -22,9 +22,8 @@ _st_H = struct.Struct('>H')
_st_Q = struct.Struct('>Q')
_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE
_ssl_ctx_fronting = ssl.create_default_context()
_ssl_ctx_fronting.check_hostname = False
class WsHandshakeError(Exception):
def __init__(self, status_code: int, status_line: str,
@@ -87,14 +86,25 @@ class RawWebSocket:
@staticmethod
async def connect(host: str, domain: str, timeout: float = 10.0,
path: str = '/apiws', *,
sni: Optional[str] = None) -> 'RawWebSocket':
sni: Optional[str] = None, secure = True) -> 'RawWebSocket':
ssl = _ssl_ctx_fronting if sni else _ssl_ctx
print(f"Connecting to {host} with secure={secure}, sni={sni}, path={path}")
if sni is None:
sni = domain
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, 443, ssl=_ssl_ctx,
server_hostname=sni),
timeout=min(timeout, 10))
(
asyncio.open_connection(
host, 443,
ssl=ssl,
server_hostname=sni,
)
if secure
else asyncio.open_connection(host, 80)
),
timeout=min(timeout, 10),
)
set_sock_opts(writer.transport, proxy_config.buffer_size)
+46 -27
View File
@@ -302,6 +302,8 @@ async def _handle_client(reader, writer, secret: bytes):
ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
target = proxy_config.dc_redirects.get(dc)
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains
domains = ws_domains(dc, is_media)
ws = None
# Fallback if DC not in config, if WS blacklisted for this DC/is_media or if connect to ip is timed out
if (dc not in proxy_config.dc_redirects
@@ -315,34 +317,40 @@ async def _handle_client(reader, writer, secret: bytes):
log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag)
else:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
label, dc, media_tag, target)
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
# Try to get WS from pool first, might be accidental timeout
ws = await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if not ws:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
label, dc, media_tag, target)
else:
log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
label, dc, media_tag, target)
if not ws:
splitter = None
try:
splitter = MsgSplitter(relay_init, proto_int)
except Exception:
pass
ok = await do_fallback(
clt_reader, clt_writer, relay_init, label,
dc, is_test_dc, is_media, media_tag,
ctx, splitter=splitter)
if not ok:
log.warning("[%s] DC%d%s no fallback available",
label, dc, media_tag)
return
ws_timeout = WS_FAIL_TIMEOUT if now < dc_fail_until.get(dc_key, 0) else 5.0
domains = ws_domains(dc, is_media)
ws = None
ws_failed_redirect = False
ws_timed_out = False
all_redirects = True
allow_pool_refill = now >= ip_fail_until.get(target, 0)
ws = await ws_pool.get(
dc, is_media, target, domains,
allow_refill=allow_pool_refill,
ws = ws or await ws_pool.get(
dc, is_media, target, domains
) if not is_test_dc else None
if ws:
log.info("[%s] DC%d%s -> pool hit via %s",
@@ -398,7 +406,7 @@ async def _handle_client(reader, writer, secret: bytes):
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
log.info("[%s] DC%d%s WS cooldown for %ds",
log.info("[%s] DC%d%s WS failed for %ds",
label, dc, media_tag, int(DC_FAIL_COOLDOWN))
splitter_fb = None
@@ -415,7 +423,6 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag)
return
dc_fail_until.pop(dc_key, None)
ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media)
stats.connections_ws += 1
@@ -524,6 +531,8 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
if proxy_config.cfproxy_worker_domains:
log.info(" CF worker: enabled (%s)",
", ".join(proxy_config.cfproxy_worker_domains))
if proxy_config.disable_secure:
log.info(" No secure: enabled (port 80 for CF proxy/worker)")
log.info("=" * 60)
log.info(" Connect:")
if ftls:
@@ -640,7 +649,7 @@ def main():
ap.add_argument('--secret', type=str, default=None,
help='MTProto proxy secret (32 hex chars). '
'Auto-generated if not provided.')
ap.add_argument('--dc-ip', metavar='DC:IP', action='append',
ap.add_argument('--dc-ip', metavar='DC:IP', nargs='?', action='append', const=None,
help='Target IP for a DC, e.g. --dc-ip 2:149.154.167.220')
ap.add_argument('-v', '--verbose', action='store_true',
help='Debug logging')
@@ -666,6 +675,8 @@ def main():
'repeatable for multiple domains)')
ap.add_argument('--no-cfproxy', action='store_true',
help='Disable Cloudflare proxy fallback')
ap.add_argument('--no-secure', action='store_true',
help='Use 80 port for CF-proxy and CF-worker connections')
ap.add_argument('--fake-tls-domain', type=str, default='',
metavar='DOMAIN',
help='Enable Fake TLS (ee-secret) masking with the given '
@@ -680,8 +691,13 @@ def main():
'(for use behind nginx/haproxy with proxy_protocol on)')
args = ap.parse_args()
if not args.dc_ip:
args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220']
if args.dc_ip is None:
args.dc_ip = [
'2:149.154.167.220',
'4:149.154.167.220',
]
elif None in args.dc_ip:
args.dc_ip = []
try:
dc_redirects = parse_dc_ip_list(args.dc_ip)
@@ -712,6 +728,7 @@ def main():
proxy_config.fallback_cfproxy = not args.no_cfproxy
proxy_config.cfproxy_user_domains = coerce_domain_list(args.cfproxy_domain)
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
proxy_config.disable_secure = args.no_secure
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
proxy_config.proxy_protocol = args.proxy_protocol
proxy_config.force_test_dc = args.force_test_dc
@@ -724,6 +741,7 @@ def main():
console = logging.StreamHandler()
console.setFormatter(log_fmt)
console.addFilter(DomainCensorFilter())
root.addHandler(console)
if args.log_file:
@@ -734,6 +752,7 @@ def main():
backups=args.log_backups,
)
fh.setFormatter(log_fmt)
fh.addFilter(DomainCensorFilter())
root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING)
+31 -3
View File
@@ -2,6 +2,8 @@ import socket as _socket
import urllib.request
import http.client
import ssl
import logging
import re
import certifi
@@ -59,9 +61,9 @@ WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media) -> List[str]:
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']
if not is_media:
return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org']
return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org']
def human_bytes(n: int) -> str:
@@ -85,6 +87,32 @@ def get_link_host(host: str) -> Optional[str]:
return host
class DomainCensorFilter(logging.Filter):
domain_pattern = re.compile(
r'(?<![\w-])(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+'
r'[a-zA-Z]{2,}(?![\w-])'
)
def _censor_match(self, match):
domain = match.group()
normalized = domain.casefold().rstrip('.')
if normalized == 'telegram.org' or normalized.endswith('.telegram.org') or normalized.endswith('.log'):
return domain
parts = domain.split('.')
if len(parts) < 2:
return domain
return '.'.join(
part if i == len(parts) - 1 else
part[:len(part) // 2] + '*' * (len(part) - len(part) // 2)
for i, part in enumerate(parts)
)
def filter(self, record):
record.msg = self.domain_pattern.sub(self._censor_match, record.getMessage())
record.args = ()
return True
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req: Request):
host = req.host.split(":")[0]
+53
View File
@@ -0,0 +1,53 @@
import time
import unittest
from collections import deque
from types import SimpleNamespace
from unittest import mock
from proxy.config import proxy_config
from proxy.pool import _WsPool
class _StopRotation(Exception):
pass
def _open_ws():
transport = SimpleNamespace(is_closing=lambda: False)
writer = SimpleNamespace(transport=transport)
return SimpleNamespace(_closed=False, writer=writer)
class WsPoolRotationTest(unittest.IsolatedAsyncioTestCase):
async def test_refills_partially_populated_bucket(self):
pool = _WsPool()
key = (2, False)
pool._idle[key] = deque([
(_open_ws(), time.monotonic()),
(_open_ws(), time.monotonic()),
])
sleep_calls = 0
async def stop_after_one_iteration(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls > 1:
raise _StopRotation
with mock.patch.object(proxy_config, 'pool_size', 4):
with mock.patch(
'proxy.pool.asyncio.sleep',
side_effect=stop_after_one_iteration):
with mock.patch.object(
pool, '_schedule_refill') as schedule_refill:
with self.assertRaises(_StopRotation):
await pool._rotate(
key, '149.154.167.220', ['example.com'])
schedule_refill.assert_called_once_with(
key, '149.154.167.220', ['example.com'])
if __name__ == '__main__':
unittest.main()
+62 -28
View File
@@ -44,19 +44,21 @@ _CFWORKER_TEST_DST = {
}
def _run_connectivity_test(cases: list) -> dict:
def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict:
import base64
from contextlib import nullcontext
import ssl
import socket as _socket
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx = ssl.create_default_context() if secure else None
port = 443 if secure else 80
results = {}
for dc, connect_host, sni_host, req_host, path in cases:
try:
with _socket.create_connection((connect_host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=sni_host) as ssock:
with _socket.create_connection((connect_host, port), timeout=5) as raw:
connection = (ctx.wrap_socket(raw, server_hostname=sni_host)
if secure else nullcontext(raw))
with connection as ssock:
ws_key = base64.b64encode(os.urandom(16)).decode()
req = (
f"GET {path} HTTP/1.1\r\n"
@@ -91,36 +93,36 @@ def _run_connectivity_test(cases: list) -> dict:
return results
def _run_cfproxy_connectivity_test(domain: str) -> dict:
def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
host = f"kws{dc}.{domain}"
cases.append((dc, host, host, host, "/apiws"))
return _run_connectivity_test(cases)
return _run_connectivity_test(cases, secure=secure)
def _run_cfworker_connectivity_test(domain: str) -> dict:
def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict:
cases = []
for dc in _CFPROXY_TEST_DCS:
dst = _CFWORKER_TEST_DST[dc]
path = f"/apiws?dst={dst}&dc={dc}&media=0"
cases.append((dc, domain, domain, domain, path))
return _run_connectivity_test(cases)
return _run_connectivity_test(cases, secure=secure)
def _run_cfproxy_multi_test(domains: list) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain) for domain in domains}
def _run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfworker_multi_test(domains: list) -> dict:
return {domain: _run_cfworker_connectivity_test(domain) for domain in domains}
def _run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict:
return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains}
def _run_cfproxy_auto_test(domains: list) -> tuple:
def _run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple:
merged: dict = {}
best_domain = None
for domain in reversed(domains):
res = _run_cfproxy_connectivity_test(domain)
res = _run_cfproxy_connectivity_test(domain, secure=secure)
if all(v is True for v in res.values()):
return domain, res
for dc, v in res.items():
@@ -350,6 +352,7 @@ class TrayConfigFormWidgets:
secret_var: Any
dc_textbox: Any
verbose_var: Any
no_secure_var: Any
adv_entries: List[Any]
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
@@ -373,6 +376,7 @@ def install_tray_config_form(
show_autostart: bool = False,
autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg)
@@ -535,6 +539,7 @@ def install_tray_config_form(
_cf_test_btn = [None]
def _on_cf_test():
secure = not no_secure_var.get()
user_domains = (
coerce_domain_list(cfproxy_user_domain_var.get())
if cf_custom_cb_var.get() else []
@@ -546,7 +551,7 @@ def install_tray_config_form(
if user_domains:
def _worker():
try:
per = _run_cfproxy_multi_test(user_domains)
per = _run_cfproxy_multi_test(user_domains, secure=secure)
if btn:
btn.after(
0,
@@ -563,7 +568,7 @@ def install_tray_config_form(
else:
def _worker_auto():
try:
ok_domain, res = _run_cfproxy_auto_test(balancer.domains)
ok_domain, res = _run_cfproxy_auto_test(balancer.domains, secure=secure)
if btn:
btn.after(
0,
@@ -671,6 +676,7 @@ def install_tray_config_form(
btn.configure(state="normal" if enabled else "disabled")
def _on_cfworker_test():
secure = not no_secure_var.get()
domains = coerce_domain_list(cfproxy_worker_domain_var.get())
btn = _cfworker_test_btn[0]
if not cfproxy_worker_enabled_var.get() or not domains or btn is None:
@@ -680,7 +686,7 @@ def install_tray_config_form(
def _worker():
try:
per = _run_cfworker_multi_test(domains)
per = _run_cfworker_multi_test(domains, secure=secure)
btn.after(
0,
lambda: _show_multi_connectivity_results(
@@ -729,6 +735,11 @@ def install_tray_config_form(
verbose_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(verbose_cb, t("tip.verbose"))
no_secure_var = ctk.BooleanVar(value=cfg.get("no_secure", False))
no_secure_cb = _checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var)
no_secure_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(no_secure_cb, t("tip.no_secure"))
adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent")
adv_frame.pack(fill="x")
@@ -776,14 +787,35 @@ def install_tray_config_form(
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), 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,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
if st.get("has_update") and on_update_click is not None:
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
upd_btn_row.pack(fill="x")
upd_btn_row.grid_columnconfigure(0, weight=1)
upd_btn_row.grid_columnconfigure(1, weight=1)
ctk.CTkButton(
upd_btn_row, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), 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,
command=lambda u=rel_url: webbrowser.open(u),
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
ctk.CTkButton(
upd_btn_row, text=t("button.update"), height=32,
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
text_color="#ffffff",
command=on_update_click,
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
else:
ctk.CTkButton(
upd_inner, text=t("button.open_release"), height=32,
font=(theme.ui_font_family, 13), 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,
command=lambda u=rel_url: webbrowser.open(u),
).pack(anchor="w")
autostart_var = None
if show_autostart:
@@ -801,7 +833,7 @@ def install_tray_config_form(
return TrayConfigFormWidgets(
host_var=host_var, port_var=port_var, secret_var=secret_var,
dc_textbox=dc_textbox, verbose_var=verbose_var,
dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var,
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
cfproxy_var=cfproxy_var,
@@ -913,6 +945,8 @@ def validate_config_form(
new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get())
if widgets.language_var is not None:
new_cfg["language"] = language_from_label(widgets.language_var.get()).value
if widgets.no_secure_var is not None:
new_cfg["no_secure"] = bool(widgets.no_secure_var.get())
return new_cfg
+3
View File
@@ -32,6 +32,7 @@
"label.cf_custom_domain": "Custom domain",
"label.cfworker_domains": "Cloudflare Worker domains (comma-separated)",
"label.verbose": "Verbose logging",
"label.no_secure": "Disable TLS for CF-proxy and CF-worker",
"label.buf_kb": "Buffer, KB (default 256)",
"label.pool_size": "WebSocket session pool (default 4)",
"label.log_max_mb": "Max log size, MB (default 5)",
@@ -44,6 +45,7 @@
"tip.secret": "Secret key for client authorization",
"tip.dc": "Mapping of Telegram data center (DC) number to web.telegram.org dc server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf connection fails then fallbacks are used",
"tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting",
"tip.no_secure": "When enabled, the proxy will use port 80 for CF-proxy and CF-worker connections",
"tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket",
"tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load",
"tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached",
@@ -113,6 +115,7 @@
"tray.restart": "Restart proxy",
"tray.settings": "Settings...",
"tray.logs": "Open logs",
"tray.update": "Update ({current} → {new})",
"tray.exit": "Exit",
"dialog.restart_title": "Restart?",
+3
View File
@@ -32,6 +32,7 @@
"label.cf_custom_domain": "Свой домен",
"label.cfworker_domains": "Cloudflare Worker домены (через запятую)",
"label.verbose": "Подробное логирование (verbose)",
"label.no_secure": "Выключиь TLS для CF-прокси и CF-worker",
"label.buf_kb": "Буфер, КБ (по умолчанию 256)",
"label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)",
"label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)",
@@ -44,6 +45,7 @@
"tip.secret": "Секретный ключ для авторизации клиентов",
"tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220",
"tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок",
"tip.no_secure": "Если включено, прокси будет использовать 80 порт для CF-прокси и CF-worker подключений",
"tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет",
"tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке",
"tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается",
@@ -113,6 +115,7 @@
"tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...",
"tray.logs": "Открыть логи",
"tray.update": "Обновить ({current} → {new})",
"tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?",
+1
View File
@@ -25,6 +25,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"cfproxy_worker_enabled": False,
"cfproxy_worker_domain": [],
"force_test_dc": False,
"no_secure": False,
}
+4
View File
@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list
from proxy.utils import DomainCensorFilter
from proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config
from utils.diagnostics import diagnose_listen_error
@@ -237,12 +238,14 @@ def setup_logging(verbose: bool = False, log_max_mb: float = 5) -> None:
fh = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
fh.addFilter(DomainCensorFilter())
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"))
ch.addFilter(DomainCensorFilter())
root.addHandler(ch)
@@ -371,6 +374,7 @@ def apply_proxy_config(cfg: dict) -> bool:
else []
)
pc.force_test_dc = cfg.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"])
pc.disable_secure = cfg.get("no_secure", DEFAULT_CONFIG["no_secure"])
return True
+63 -21
View File
@@ -34,7 +34,7 @@ try:
except ImportError:
Image = None
from proxy import get_link_host
from proxy import __version__, get_link_host
from utils.win32_theme import (
is_windows_dark_theme,
@@ -47,6 +47,9 @@ from utils.tray_common import (
quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url,
)
from utils.update_check import (
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
)
from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer,
@@ -62,6 +65,7 @@ _tray_icon: Optional[object] = None
_config: dict = {}
_exiting = False
_win_mutex_handle = None
_update_flow_lock = threading.Lock()
_ERROR_ALREADY_EXISTS = 183
@@ -146,6 +150,7 @@ def update_ctk_form(
width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100,
theme=theme,
topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH),
)
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -203,7 +208,7 @@ def update_ctk_form(
btns.append(btn_upd)
btn_pg = ctk.CTkButton(
row, text=t("button.page"), width=88, height=34,
font=(theme.ui_font_family, 13), command=lambda: _close_with("open"),
font=(theme.ui_font_family, 13), command=lambda: webbrowser.open(release_url or RELEASES_PAGE_URL),
)
btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg)
@@ -317,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
os._exit(0)
def _trigger_update_flow(icon=None, item=None) -> None:
"""Show the update dialog for an update already known to be available.
Reused by the tray "Update" item and the settings dialog's "Update"
button, so an update can still be started after the initial startup
prompt was skipped/closed.
"""
if not _update_flow_lock.acquire(blocking=False):
return
def _show() -> None:
try:
if _exiting:
return
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 "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
except Exception as exc:
log.warning("Update flow failed: %s", repr(exc))
finally:
_update_flow_lock.release()
threading.Thread(target=_show, daemon=True, name="manual-update").start()
def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True):
return
@@ -326,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
if is_exiting():
return
try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update") or is_exiting():
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
if is_exiting():
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
choice = update_ctk_form(
t("update.available", version=ver),
download_url=asset[0] if asset else None,
release_url=url,
)
if choice == "open":
webbrowser.open(url)
_trigger_update_flow()
except Exception as exc:
log.warning("Update check failed: %s", repr(exc))
@@ -481,7 +511,7 @@ def _edit_config_dialog() -> None:
root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
topmost=False, 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)
@@ -498,6 +528,7 @@ def _edit_config_dialog() -> None:
show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
)
_original_appearance = ctk.get_appearance_mode()
@@ -579,7 +610,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme,
after_create=lambda r: r.iconbitmap(ICON_PATH),
topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH),
)
def on_done(open_tg: bool) -> None:
@@ -602,7 +633,7 @@ def _build_menu():
host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host)
return pystray.Menu(
items = [
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR,
@@ -611,7 +642,18 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit),
)
]
st = get_status()
if st.get("has_update"):
items[-2:-2] = [
pystray.Menu.SEPARATOR,
pystray.MenuItem(
t("tray.update", current=__version__, new=st.get("latest") or "?"),
_trigger_update_flow,
)
]
return pystray.Menu(*items)
# entry point