Compare commits

..

15 Commits

Author SHA1 Message Date
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
20 changed files with 315 additions and 93 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 path: dist/TgWsProxy_macos_universal.dmg
build-linux: build-linux:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
if: ${{ github.event.inputs.build_linux == 'true' }} if: ${{ github.event.inputs.build_linux == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
@@ -395,6 +395,8 @@ jobs:
python3-venv \ python3-venv \
python3-dev \ python3-dev \
python3-gi \ python3-gi \
python3-gi-cairo \
gir1.2-appindicator3-0.1 \
gir1.2-ayatanaappindicator3-0.1 \ gir1.2-ayatanaappindicator3-0.1 \
python3-tk python3-tk
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
The project is completely free for everyone. The project is completely free for everyone.
However, its development and stable operation as the user base grows require investment. However, its development and stable operation as the user base grows require investment.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Other coins**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
Проект полностью бесплатен для всех. Проект полностью бесплатен для всех.
Однако его развитие и стабильная работа при росте числа пользователей требуют вложений. Однако его развитие и стабильная работа при росте числа пользователей требуют вложений.
-1
View File
@@ -20,7 +20,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1`
> **Другие монеты**: https://nowpayments.io/donation/flowseal
> [!CAUTION] > [!CAUTION]
> >
-1
View File
@@ -5,7 +5,6 @@
> **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu` > **USDT (TRC20)**: `TXPnKs2Ww1RD8JN6nChFUVmi5r2hqrWjuu`
> **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w` > **BTC**: `bc1qr8vd6jelkyyry3m4mq6z5txdx4pl856fu6ss0w`
> **ETH**: `0x1417878fdc5047E670a77748B34819b9A49C72F1` > **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() theme = ctk_theme_for_platform()
w, h = CONFIG_DIALOG_SIZE w, h = CONFIG_DIALOG_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, ctk, title=t("app.settings_title"), width=w, height=h,
after_create=_apply_window_icon, theme=theme, topmost=False, after_create=_apply_window_icon
) )
fpx, fpy = CONFIG_DIALOG_FRAME_PAD fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) 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 w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme, 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: def on_done(open_tg: bool) -> None:
@@ -310,7 +310,47 @@ def run_tray() -> None:
log.info("Tray app exited") 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: def main() -> None:
if sys.argv[1:] == ['--smoke-test']:
_smoke_test()
return
if not acquire_lock(): if not acquire_lock():
_show_info(t("dialog.already_running"), os.path.basename(sys.argv[0])) _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0]))
return return
+2
View File
@@ -348,6 +348,7 @@ def _edit_config_dialog() -> None:
width=width, width=width,
height=height, height=height,
theme=theme, theme=theme,
topmost=False,
after_create=lambda window: _activate_app(), after_create=lambda window: _activate_app(),
) )
_settings_window = root _settings_window = root
@@ -471,6 +472,7 @@ def _show_first_run() -> None:
width=width, width=width,
height=height, height=height,
theme=theme, theme=theme,
topmost=False,
after_create=lambda window: _activate_app(), after_create=lambda window: _activate_app(),
) )
+40 -16
View File
@@ -1,10 +1,9 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import sys
import os import os
import glob import glob
from PyInstaller.utils.hooks import collect_submodules, collect_data_files from PyInstaller.utils.hooks import collect_data_files
block_cipher = None 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') _i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
# Collect gi (PyGObject) submodules and data so pystray._appindicator works appindicator_binaries = [
gi_hiddenimports = collect_submodules('gi') (path, '.')
gi_datas = collect_data_files('gi') for pattern in ('/usr/lib/*/libappindicator3.so.1',
'/usr/lib/libappindicator3.so.1', '/usr/lib64/libappindicator3.so.1')
# Collect GObject typelib files from the system for path in glob.glob(pattern)
typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0') ]
typelib_datas = []
for d in typelib_dirs:
typelib_datas.append((d, 'gi_typelibs'))
a = Analysis( a = Analysis(
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
pathex=[], pathex=[],
binaries=[], binaries=appindicator_binaries,
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas + gi_datas + typelib_datas, datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
hiddenimports=[ hiddenimports=[
'pystray._appindicator', 'pystray._appindicator',
'PIL._tkinter_finder', 'PIL._tkinter_finder',
@@ -39,15 +35,22 @@ a = Analysis(
'cryptography.hazmat.primitives.ciphers.modes', 'cryptography.hazmat.primitives.ciphers.modes',
'cryptography.hazmat.backends.openssl', 'cryptography.hazmat.backends.openssl',
'gi', 'gi',
'_gi',
'gi.repository.GLib', 'gi.repository.GLib',
'gi.repository.GObject', 'gi.repository.GObject',
'gi.repository.Gtk', 'gi.repository.Gtk',
'gi.repository.Gdk', 'gi.repository.Gdk',
'gi.repository.DBus',
'gi.repository.AppIndicator3',
'gi.repository.AyatanaAppIndicator3', 'gi.repository.AyatanaAppIndicator3',
] + gi_hiddenimports, ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={
'gi': {
'icons': [],
'themes': [],
'languages': ['en', 'ru'],
},
},
runtime_hooks=[], runtime_hooks=[],
excludes=[ excludes=[
'PIL._avif', 'PIL._avif',
@@ -58,6 +61,27 @@ a = Analysis(
cipher=block_cipher, 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 = { _PIL_EXCLUDE_PYDS = {
'_avif', '_webp', '_imagingtk', '_avif', '_webp', '_imagingtk',
'FpxImagePlugin', 'MicImagePlugin', 'FpxImagePlugin', 'MicImagePlugin',
+1 -1
View File
@@ -1,6 +1,6 @@
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
from .utils import get_link_host, build_github_opener from .utils import get_link_host, build_github_opener
__version__ = "1.10.0" __version__ = "1.10.1"
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"] __all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
+11 -10
View File
@@ -14,10 +14,12 @@ from .utils import ws_domains, DC_DEFAULT_IPS
log = logging.getLogger('tg-mtproto-proxy') 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: class _WsPool:
WS_POOL_MAX_AGE = 120.0 WS_POOL_MAX_AGE = 120.0
WS_POOL_CHECK_INTERVAL = 5.0 WS_POOL_CHECK_INTERVAL = 5.0
REFILL_BACKOFF_INITIAL = 60.0 REFILL_BACKOFF_INITIAL = 1.0
REFILL_BACKOFF_MAX = 3600.0 REFILL_BACKOFF_MAX = 3600.0
def __init__(self): def __init__(self):
@@ -26,11 +28,10 @@ class _WsPool:
self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {} self._rotating: Dict[Tuple[int, bool], asyncio.Task] = {}
self._refill_failures: Dict[Tuple[int, bool], int] = {} self._refill_failures: Dict[Tuple[int, bool], int] = {}
self._refill_after: Dict[Tuple[int, bool], float] = {} 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, async def get(self, dc: int, is_media: bool,
target_ip: str, domains: List[str], target_ip: str, domains: List[str]
*, allow_refill: bool = True
) -> Optional[RawWebSocket]: ) -> Optional[RawWebSocket]:
key = (dc, is_media) key = (dc, is_media)
now = time.monotonic() now = time.monotonic()
@@ -50,13 +51,11 @@ class _WsPool:
log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)", log.debug("WS pool hit DC%d%s (age=%.1fs, left=%d)",
dc, 'm' if is_media else '', age, len(bucket)) dc, 'm' if is_media else '', age, len(bucket))
self.report_success(dc, is_media) 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 return ws
stats.pool_misses += 1 stats.pool_misses += 1
if allow_refill: self._schedule_refill(key, target_ip, domains)
self._schedule_refill(key, target_ip, domains)
return None return None
def _schedule_refill(self, key, target_ip, domains): def _schedule_refill(self, key, target_ip, domains):
@@ -98,7 +97,7 @@ class _WsPool:
self._refill_failures[key] = failures self._refill_failures[key] = failures
delay = min( delay = min(
self.REFILL_BACKOFF_INITIAL self.REFILL_BACKOFF_INITIAL
* (2 ** min(failures - 1, 6)), * (2 ** min(failures - 1, 12)),
self.REFILL_BACKOFF_MAX, self.REFILL_BACKOFF_MAX,
) )
self._refill_after[key] = time.monotonic() + delay self._refill_after[key] = time.monotonic() + delay
@@ -150,6 +149,8 @@ class _WsPool:
log.debug( log.debug(
"WS pool rotated DC%d%s: %d stale, %d ready", "WS pool rotated DC%d%s: %d stale, %d ready",
dc, 'm' if is_media else '', len(expired), len(bucket)) dc, 'm' if is_media else '', len(expired), len(bucket))
if len(bucket) < proxy_config.pool_size:
self._schedule_refill(key, target_ip, domains) self._schedule_refill(key, target_ip, domains)
finally: finally:
if self._rotating.get(key) is asyncio.current_task(): if self._rotating.get(key) is asyncio.current_task():
@@ -166,7 +167,7 @@ class _WsPool:
target_ip, domain, timeout=8) target_ip, domain, timeout=8)
self.try_fronting_first = False self.try_fronting_first = False
return ws return ws
except asyncio.TimeoutError: except (asyncio.TimeoutError, ConnectionResetError):
if self.try_fronting_first: if self.try_fronting_first:
return None return None
return await self._connect_fronted(target_ip, domain) return await self._connect_fronted(target_ip, domain)
+33 -24
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 ws_path = WS_PATH_TEST if is_test_dc else WS_PATH
target = proxy_config.dc_redirects.get(dc) target = proxy_config.dc_redirects.get(dc)
is_any_cf_fallback = proxy_config.fallback_cfproxy or proxy_config.cfproxy_worker_domains 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 # 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 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", log.info("[%s] DC%d%s WS blacklisted -> fallback",
label, dc, media_tag) label, dc, media_tag)
else: else:
log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback", # Try to get WS from pool first, might be accidental timeout
label, dc, media_tag, target) ws = await ws_pool.get(
splitter = None dc, is_media, target, domains
try: ) if not is_test_dc else None
splitter = MsgSplitter(relay_init, proto_int)
except Exception: if not ws:
pass log.info("[%s] DC%d%s WS connect to %s was timed out -> fallback",
ok = await do_fallback( label, dc, media_tag, target)
clt_reader, clt_writer, relay_init, label, else:
dc, is_test_dc, is_media, media_tag, log.info("[%s] DC%d%s WS connect to %s was timed out, but pool hit -> using WS",
ctx, splitter=splitter) label, dc, media_tag, target)
if not ok:
log.warning("[%s] DC%d%s no fallback available", if not ws:
label, dc, media_tag) splitter = None
return 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 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_failed_redirect = False
ws_timed_out = False ws_timed_out = False
all_redirects = True all_redirects = True
allow_pool_refill = now >= ip_fail_until.get(target, 0) ws = ws or await ws_pool.get(
ws = await ws_pool.get( dc, is_media, target, domains
dc, is_media, target, domains,
allow_refill=allow_pool_refill,
) if not is_test_dc else None ) if not is_test_dc else None
if ws: if ws:
log.info("[%s] DC%d%s -> pool hit via %s", 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 dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN
else: else:
dc_fail_until[dc_key] = now + DC_FAIL_COOLDOWN 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)) label, dc, media_tag, int(DC_FAIL_COOLDOWN))
splitter_fb = None splitter_fb = None
@@ -415,7 +423,6 @@ async def _handle_client(reader, writer, secret: bytes):
label, dc, media_tag) label, dc, media_tag)
return return
dc_fail_until.pop(dc_key, None)
ip_fail_until.pop(target, None) ip_fail_until.pop(target, None)
ws_pool.report_success(dc, is_media) ws_pool.report_success(dc, is_media)
stats.connections_ws += 1 stats.connections_ws += 1
@@ -724,6 +731,7 @@ def main():
console = logging.StreamHandler() console = logging.StreamHandler()
console.setFormatter(log_fmt) console.setFormatter(log_fmt)
console.addFilter(DomainCensorFilter())
root.addHandler(console) root.addHandler(console)
if args.log_file: if args.log_file:
@@ -734,6 +742,7 @@ def main():
backups=args.log_backups, backups=args.log_backups,
) )
fh.setFormatter(log_fmt) fh.setFormatter(log_fmt)
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
logging.getLogger('asyncio').setLevel(logging.WARNING) logging.getLogger('asyncio').setLevel(logging.WARNING)
+31 -3
View File
@@ -2,6 +2,8 @@ import socket as _socket
import urllib.request import urllib.request
import http.client import http.client
import ssl import ssl
import logging
import re
import certifi import certifi
@@ -59,9 +61,9 @@ WS_PATH_TEST = WS_PATH + '_test'
def ws_domains(dc: int, is_media) -> List[str]: def ws_domains(dc: int, is_media) -> List[str]:
if dc == 203: if dc == 203:
dc = 2 dc = 2
if is_media is None or is_media: if not 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']
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: def human_bytes(n: int) -> str:
@@ -85,6 +87,32 @@ def get_link_host(host: str) -> Optional[str]:
return host 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): class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req: Request): def https_open(self, req: Request):
host = req.host.split(":")[0] 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()
+30 -8
View File
@@ -373,6 +373,7 @@ def install_tray_config_form(
show_autostart: bool = False, show_autostart: bool = False,
autostart_value: bool = False, autostart_value: bool = False,
on_language_change: Optional[Callable[[], None]] = None, on_language_change: Optional[Callable[[], None]] = None,
on_update_click: Optional[Callable[[], None]] = None,
) -> TrayConfigFormWidgets: ) -> TrayConfigFormWidgets:
lang_cfg = cfg.get("language", default_config["language"]) lang_cfg = cfg.get("language", default_config["language"])
set_language(lang_cfg) set_language(lang_cfg)
@@ -776,14 +777,35 @@ def install_tray_config_form(
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) 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
ctk.CTkButton( if st.get("has_update") and on_update_click is not None:
upd_inner, text=t("button.open_release"), height=32, upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
font=(theme.ui_font_family, 13), corner_radius=8, upd_btn_row.pack(fill="x")
fg_color=theme.field_bg, hover_color=theme.field_border, upd_btn_row.grid_columnconfigure(0, weight=1)
text_color=theme.text_primary, border_width=1, upd_btn_row.grid_columnconfigure(1, weight=1)
border_color=theme.field_border, ctk.CTkButton(
command=lambda u=rel_url: webbrowser.open(u), upd_btn_row, text=t("button.open_release"), height=32,
).pack(anchor="w") 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 autostart_var = None
if show_autostart: if show_autostart:
+1
View File
@@ -113,6 +113,7 @@
"tray.restart": "Restart proxy", "tray.restart": "Restart proxy",
"tray.settings": "Settings...", "tray.settings": "Settings...",
"tray.logs": "Open logs", "tray.logs": "Open logs",
"tray.update": "Update ({current} → {new})",
"tray.exit": "Exit", "tray.exit": "Exit",
"dialog.restart_title": "Restart?", "dialog.restart_title": "Restart?",
+1
View File
@@ -113,6 +113,7 @@
"tray.restart": "Перезапустить прокси", "tray.restart": "Перезапустить прокси",
"tray.settings": "Настройки...", "tray.settings": "Настройки...",
"tray.logs": "Открыть логи", "tray.logs": "Открыть логи",
"tray.update": "Обновить ({current} → {new})",
"tray.exit": "Выход", "tray.exit": "Выход",
"dialog.restart_title": "Перезапустить?", "dialog.restart_title": "Перезапустить?",
+3
View File
@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
import psutil import psutil
from proxy import __version__, get_link_host, parse_dc_ip_list, proxy_config, coerce_domain_list 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 proxy.tg_ws_proxy import _run
from utils.default_config import default_tray_config from utils.default_config import default_tray_config
from utils.diagnostics import diagnose_listen_error 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 = build_log_handler(str(LOG_FILE), log_max_mb=log_max_mb, backups=1)
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S")) fh.setFormatter(logging.Formatter(_LOG_FMT_FILE, datefmt="%Y-%m-%d %H:%M:%S"))
fh.addFilter(DomainCensorFilter())
root.addHandler(fh) root.addHandler(fh)
if not IS_FROZEN: if not IS_FROZEN:
ch = logging.StreamHandler(sys.stdout) ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level) ch.setLevel(level)
ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S")) ch.setFormatter(logging.Formatter(_LOG_FMT_CONSOLE, datefmt="%H:%M:%S"))
ch.addFilter(DomainCensorFilter())
root.addHandler(ch) root.addHandler(ch)
+63 -21
View File
@@ -34,7 +34,7 @@ try:
except ImportError: except ImportError:
Image = None Image = None
from proxy import get_link_host from proxy import __version__, get_link_host
from utils.win32_theme import ( from utils.win32_theme import (
is_windows_dark_theme, is_windows_dark_theme,
@@ -47,6 +47,9 @@ from utils.tray_common import (
quit_ctk, release_lock, restart_proxy, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, 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 ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, populate_first_run_window, tray_settings_scroll_and_footer,
@@ -62,6 +65,7 @@ _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_win_mutex_handle = None _win_mutex_handle = None
_update_flow_lock = threading.Lock()
_ERROR_ALREADY_EXISTS = 183 _ERROR_ALREADY_EXISTS = 183
@@ -146,6 +150,7 @@ def update_ctk_form(
width=310 if IS_FROZEN else 210, width=310 if IS_FROZEN else 210,
height=130 if IS_FROZEN else 100, height=130 if IS_FROZEN else 100,
theme=theme, theme=theme,
topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
) )
frame = main_content_frame(ctk, root, theme, padx=16, pady=14) frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
@@ -203,7 +208,7 @@ def update_ctk_form(
btns.append(btn_upd) btns.append(btn_upd)
btn_pg = ctk.CTkButton( btn_pg = ctk.CTkButton(
row, text=t("button.page"), width=88, height=34, 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)) btn_pg.pack(side="left", padx=(0, 6))
btns.append(btn_pg) btns.append(btn_pg)
@@ -317,6 +322,42 @@ def _perform_update(download_url: str, set_status=None) -> None:
os._exit(0) 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: def _maybe_do_update(cfg: dict, is_exiting) -> None:
if not cfg.get("check_updates", True): if not cfg.get("check_updates", True):
return return
@@ -326,23 +367,12 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
if is_exiting(): if is_exiting():
return return
try: try:
from proxy import __version__
from utils.update_check import RELEASES_PAGE_URL, get_status, get_update_asset, run_check
run_check(__version__) run_check(__version__)
st = get_status() if _tray_icon is not None:
if not st.get("has_update") or is_exiting(): _tray_icon.menu = _build_menu()
if is_exiting():
return return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL _trigger_update_flow()
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: except Exception as exc:
log.warning("Update check failed: %s", repr(exc)) log.warning("Update check failed: %s", repr(exc))
@@ -481,7 +511,7 @@ def _edit_config_dialog() -> None:
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, 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 fpx, fpy = CONFIG_DIALOG_FRAME_PAD
frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy)
@@ -498,6 +528,7 @@ def _edit_config_dialog() -> None:
show_autostart=_supports_autostart(), show_autostart=_supports_autostart(),
autostart_value=cfg.get("autostart", False), autostart_value=cfg.get("autostart", False),
on_language_change=_refresh_tray_menu, on_language_change=_refresh_tray_menu,
on_update_click=_trigger_update_flow,
) )
_original_appearance = ctk.get_appearance_mode() _original_appearance = ctk.get_appearance_mode()
@@ -579,7 +610,7 @@ def _show_first_run() -> None:
w, h = FIRST_RUN_SIZE w, h = FIRST_RUN_SIZE
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, title=t("app.name"), width=w, height=h, theme=theme, 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: def on_done(open_tg: bool) -> None:
@@ -602,7 +633,7 @@ def _build_menu():
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 = get_link_host(host) 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.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
@@ -611,7 +642,18 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit), 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 # entry point