mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-09-06 10:07:02 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be485a3851 | |||
| f811a10918 | |||
| 55ec6a91df | |||
| b23c8f6122 | |||
| d36bb7df71 | |||
| bd4f3d080c | |||
| 492d999248 | |||
| e8ad0d6958 | |||
| 5eb7e007d3 | |||
| 1ae2f3a147 | |||
| d2b6c450eb | |||
| 1b8671b3d3 |
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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)
|
||||||
|
|||||||
+31
-24
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -59,9 +59,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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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": "Перезапустить?",
|
||||||
|
|||||||
+63
-21
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user