feat: автообновление через отдельные shell-скрипты на Windows, Linux и macOS

Добавлено механизм самообновления: приложение скачивает и заменяет текущий бинарник через отдельный скрипт PowerShell (.ps1) или Bash (.sh), обходя ограничение на замену запущенного исполняемого файла без отдельного updater-приложения.

utils/updater.py (новый): определение платформенных файлов, проверка прав на запись, генерация скриптов для Windows/Linux/macOS; на macOS — монтирование DMG, копирование .app с откатом при ошибке, снятие quarantine, перезапуск.
utils/update_check.py: кэширует assets релиза локально, чтобы при часовом rate limit они были доступны для отложенного обновления.
utils/default_config.py: auto_update = True по умолчанию.
utils/tray_common.py: maybe_notify_update теперь запускает автоустановку при auto_update=True и is_supported(), иначе показывает ручной диалог; добавлены колбэки on_update_available / on_exit.
windows.py / linux.py: _on_update_available добавляет пункт трея «Обновить до X», _on_do_update запускает обновление и корректный выход.
macos.py: переход на общий maybe_notify_update вместо ad-hoc логики; добавлен пункт меню обновления, переключатель автообновления и безопасный выход через главный поток с принудительным завершением через 3 секунды.
ui/ctk_tray_ui.py: чекбокс «Устанавливать обновления автоматически» в разделе Updates, виден только при is_supported().
This commit is contained in:
Overl1te 2026-04-23 15:28:25 +03:00
parent 28be00ea9e
commit c47beab064
8 changed files with 491 additions and 45 deletions

View file

@ -34,6 +34,7 @@ from ui.ctk_theme import (
_tray_icon: Optional[object] = None
_config: dict = {}
_exiting = False
_update_version: Optional[str] = None
# dialogs (tkinter messagebox)
@ -121,6 +122,28 @@ def _on_open_logs(icon=None, item=None) -> None:
_show_info("Файл логов ещё не создан.")
def _on_update_available(version: str) -> None:
global _update_version
_update_version = version
if _tray_icon:
_tray_icon.menu = _build_menu()
def _on_do_update(icon=None, item=None) -> None:
from utils.updater import run_update
from utils.update_check import get_status
st = get_status()
assets = st.get("assets") or []
ver = st.get("latest") or "?"
if run_update(assets, ver):
_on_exit(_tray_icon)
else:
_show_error(
"Не удалось запустить автообновление.\n"
"Скачайте новую версию вручную на странице релиза."
)
def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
@ -228,7 +251,13 @@ 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 = []
if _update_version:
items += [
pystray.MenuItem(f"Обновить до {_update_version}", _on_do_update),
pystray.Menu.SEPARATOR,
]
items += [
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
@ -237,7 +266,8 @@ def _build_menu():
pystray.MenuItem("Открыть логи", _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit),
)
]
return pystray.Menu(*items)
# entry point
@ -260,7 +290,11 @@ def run_tray() -> None:
return
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
maybe_notify_update(
_config, lambda: _exiting, _ask_yes_no,
on_update_available=_on_update_available,
on_exit=lambda: _on_exit(_tray_icon),
)
_show_first_run()
check_ipv6_warning(_show_info)

140
macos.py
View file

@ -30,7 +30,8 @@ from proxy.tg_ws_proxy import _run
from utils.tray_common import (
APP_DIR, APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IPV6_WARN_MARKER,
LOG_FILE, acquire_lock, apply_proxy_config, ensure_dirs, load_config,
log, release_lock, save_config, setup_logging, stop_proxy, tg_proxy_url,
log, maybe_notify_update, release_lock, save_config, setup_logging,
stop_proxy, tg_proxy_url,
)
MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png"
@ -40,6 +41,7 @@ _async_stop: Optional[object] = None
_app: Optional[object] = None
_config: dict = {}
_exiting: bool = False
_update_version: Optional[str] = None
# osascript dialogs
@ -280,6 +282,19 @@ def _toggle_check_updates(_=None) -> None:
_app._check_updates_item.title = _check_updates_menu_title()
def _auto_update_menu_title() -> str:
on = bool(_config.get("auto_update", True))
return "✓ Устанавливать обновления автоматически" if on else "Устанавливать обновления автоматически (выкл)"
def _toggle_auto_update(_=None) -> None:
global _config
_config["auto_update"] = not bool(_config.get("auto_update", True))
save_config(_config)
if _app is not None and getattr(_app, "_auto_update_item", None) is not None:
_app._auto_update_item.title = _auto_update_menu_title()
def _on_open_release_page(_=None) -> None:
from utils.update_check import RELEASES_PAGE_URL
webbrowser.open(RELEASES_PAGE_URL)
@ -288,30 +303,65 @@ def _on_open_release_page(_=None) -> None:
# update check
def _maybe_notify_update_async() -> None:
def _work():
time.sleep(1.5)
if _exiting:
return
if not _config.get("check_updates", True):
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
if _ask_yes_no(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",
):
webbrowser.open(url)
except Exception as exc:
log.warning("Update check failed: %s", exc)
def _run_on_main(fn) -> None:
try:
from PyObjCTools import AppHelper
AppHelper.callAfter(fn)
except Exception as exc:
log.warning("Main-thread dispatch failed: %s", exc)
threading.Thread(target=_work, daemon=True, name="update-check").start()
def _insert_update_menu_item() -> None:
if _app is None or _update_version is None:
return
try:
title = f"Обновить до {_update_version}"
if title in _app.menu:
return
item = rumps.MenuItem(title, callback=_on_do_update)
keys = list(_app.menu.keys())
if keys:
_app.menu.insert_before(keys[0], item)
else:
_app.menu[title] = item
except Exception as exc:
log.warning("Failed to insert update menu item: %s", exc)
def _on_update_available(version: str) -> None:
global _update_version
_update_version = version
if _app is not None:
_run_on_main(_insert_update_menu_item)
def _on_do_update(_=None) -> None:
from utils.update_check import get_status
from utils.updater import run_update
st = get_status()
assets = st.get("assets") or []
ver = st.get("latest") or "?"
if run_update(assets, ver):
_quit_rumps()
else:
_show_error(
"Не удалось запустить автообновление.\n"
"Скачайте новую версию вручную на странице релиза."
)
def _quit_rumps() -> None:
global _exiting
if _exiting:
os._exit(0)
return
_exiting = True
log.info("Quitting menubar app")
_run_on_main(rumps.quit_application)
threading.Thread(
target=lambda: (time.sleep(3), os._exit(0)),
daemon=True, name="force-exit",
).start()
# settings dialog
@ -536,24 +586,34 @@ class TgWsProxyApp(_TgWsProxyAppBase):
)
self._version_item = rumps.MenuItem(f"Версия {__version__}", callback=lambda _: None)
from utils.updater import is_supported as _upd_supported
self._auto_update_item = None
if _upd_supported():
self._auto_update_item = rumps.MenuItem(
_auto_update_menu_title(), callback=_toggle_auto_update
)
menu_items = [
self._open_tg_item,
self._copy_link_item,
None,
self._restart_item,
self._settings_item,
self._logs_item,
None,
self._release_page_item,
self._check_updates_item,
]
if self._auto_update_item is not None:
menu_items.append(self._auto_update_item)
menu_items.extend([None, self._version_item])
super().__init__(
"TG WS Proxy",
icon=icon_path,
template=False,
quit_button="Выход",
menu=[
self._open_tg_item,
self._copy_link_item,
None,
self._restart_item,
self._settings_item,
self._logs_item,
None,
self._release_page_item,
self._check_updates_item,
None,
self._version_item,
],
menu=menu_items,
)
def update_menu_title(self) -> None:
@ -597,7 +657,11 @@ def run_menubar() -> None:
return
_start_proxy()
_maybe_notify_update_async()
maybe_notify_update(
_config, lambda: _exiting, _ask_yes_no,
on_update_available=_on_update_available,
on_exit=_quit_rumps,
)
_show_first_run()
_check_ipv6_warning()

View file

@ -52,6 +52,10 @@ _TIP_AUTOSTART = (
"Если вы переместите программу в другую папку, автозапуск сбросится"
)
_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений"
_TIP_AUTO_UPDATE = (
"Автоматически скачивать и устанавливать обновления без подтверждения.\n"
"Если выключено — при наличии обновления появится кнопка в трее."
)
_TIP_CFPROXY = (
"Использовать Cloudflare прокси для недоступных датацентров"
)
@ -295,6 +299,7 @@ class TrayConfigFormWidgets:
adv_keys: Tuple[str, ...]
autostart_var: Optional[Any]
check_updates_var: Optional[Any]
auto_update_var: Optional[Any] = None
cfproxy_var: Optional[Any] = None
cfproxy_priority_var: Optional[Any] = None
cfproxy_user_domain_var: Optional[Any] = None
@ -540,6 +545,16 @@ def install_tray_config_form(
upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(upd_cb, _TIP_CHECK_UPDATES)
from utils.updater import is_supported as _upd_supported
auto_update_var = None
if _upd_supported():
auto_update_var = ctk.BooleanVar(
value=bool(cfg.get("auto_update", default_config.get("auto_update", True)))
)
auto_upd_cb = _checkbox(ctk, upd_inner, theme, "Устанавливать обновления автоматически", auto_update_var)
auto_upd_cb.pack(anchor="w", pady=(0, 6))
attach_ctk_tooltip(auto_upd_cb, _TIP_AUTO_UPDATE)
if st.get("error"):
upd_status = "Не удалось связаться с GitHub. Проверьте сеть."
elif not st.get("checked"):
@ -589,6 +604,7 @@ def install_tray_config_form(
dc_textbox=dc_textbox, verbose_var=verbose_var,
adv_entries=adv_entries, adv_keys=adv_keys,
autostart_var=autostart_var, check_updates_var=check_updates_var,
auto_update_var=auto_update_var,
cfproxy_var=cfproxy_var,
cfproxy_priority_var=cfproxy_priority_var,
cfproxy_user_domain_var=cfproxy_user_domain_var,
@ -669,6 +685,8 @@ def validate_config_form(
merge_adv_from_form(widgets, new_cfg, default_config)
if widgets.check_updates_var is not None:
new_cfg["check_updates"] = bool(widgets.check_updates_var.get())
if widgets.auto_update_var is not None:
new_cfg["auto_update"] = bool(widgets.auto_update_var.get())
if widgets.cfproxy_var is not None:
new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get())
if widgets.cfproxy_priority_var is not None:

View file

@ -14,6 +14,7 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = {
"dc_ip": ["2:149.154.167.220", "4:149.154.167.220"],
"verbose": False,
"check_updates": True,
"auto_update": True,
"log_max_mb": 5,
"buf_kb": 256,
"pool_size": 4,

View file

@ -367,6 +367,9 @@ def maybe_notify_update(
cfg: dict,
is_exiting: Callable[[], bool],
ask_open: Callable[[str, str], bool],
*,
on_update_available: Optional[Callable[[str], None]] = None,
on_exit: Optional[Callable[[], None]] = None,
) -> None:
if not cfg.get("check_updates", True):
return
@ -377,14 +380,29 @@ def maybe_notify_update(
return
try:
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
from utils.updater import is_supported, run_update
import webbrowser
run_check(__version__)
st = get_status()
if not st.get("has_update"):
return
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?"
assets = st.get("assets") or []
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
auto_update = cfg.get("auto_update", True)
if auto_update and is_supported() and assets:
if run_update(assets, ver):
if on_exit:
on_exit()
return
# launch failed — fall through to dialog
if not auto_update and on_update_available:
on_update_available(ver)
if ask_open(
f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?",
"TG WS Proxy — обновление",

View file

@ -30,6 +30,7 @@ _state: Dict[str, Any] = {
"latest": None,
"html_url": None,
"error": None,
"assets": [],
}
@ -162,6 +163,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip()
if tag:
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
_state["assets"] = cache.get("assets") or []
return
err = cache.get("last_error")
_state["error"] = (
@ -181,6 +183,7 @@ def run_check(current_version: str) -> None:
tag = (cache.get("tag_name") or "").strip()
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
_apply_release_tag(tag, url, current_version)
_state["assets"] = cache.get("assets") or []
if new_etag:
cache["etag"] = new_etag
_save_cache(cache_path, cache)
@ -189,6 +192,7 @@ def run_check(current_version: str) -> None:
assert data is not None
tag = (data.get("tag_name") or "").strip()
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
assets = data.get("assets") or []
if not tag:
_state["has_update"] = False
_state["ahead_of_release"] = False
@ -196,10 +200,12 @@ def run_check(current_version: str) -> None:
_state["html_url"] = html_url
else:
_apply_release_tag(tag, html_url, current_version)
_state["assets"] = assets
if new_etag:
cache["etag"] = new_etag
cache["tag_name"] = tag
cache["html_url"] = html_url
cache["assets"] = assets
cache.pop("last_error", None)
_save_cache(cache_path, cache)
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:

271
utils/updater.py Normal file
View file

@ -0,0 +1,271 @@
from __future__ import annotations
import logging
import os
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional
log = logging.getLogger('tg-mtproto-proxy')
def _find_app_bundle() -> Optional[Path]:
"""On macOS, walk up sys.executable to find the enclosing .app bundle."""
if sys.platform != 'darwin':
return None
try:
p = Path(sys.executable).resolve()
except OSError:
return None
for _ in range(8):
if p.suffix == '.app' and p.is_dir():
return p
if p == p.parent:
return None
p = p.parent
return None
def _can_write_target() -> bool:
"""Check if we have write permission for the install target."""
try:
if sys.platform == 'darwin':
app = _find_app_bundle()
if app is None:
return False
return os.access(str(app), os.W_OK) and os.access(str(app.parent), os.W_OK)
exe = Path(sys.executable)
return os.access(str(exe), os.W_OK) and os.access(str(exe.parent), os.W_OK)
except OSError:
return False
def is_supported() -> bool:
"""Auto-update is supported on frozen builds where we can write to the install target."""
if not getattr(sys, 'frozen', False):
return False
if sys.platform not in ('win32', 'linux', 'darwin'):
return False
return _can_write_target()
def find_asset_url(assets: list) -> Optional[str]:
name = _asset_name()
if name is None:
return None
for asset in assets:
if asset.get('name') == name:
return asset.get('browser_download_url')
return None
def _asset_name() -> Optional[str]:
if sys.platform == 'win32':
ver = sys.getwindowsversion()
if ver.major >= 10:
return 'TgWsProxy_windows.exe'
bits = struct.calcsize('P') * 8
return f'TgWsProxy_windows_7_{"64bit" if bits == 64 else "32bit"}.exe'
if sys.platform == 'linux':
return 'TgWsProxy_linux_amd64'
if sys.platform == 'darwin':
return 'TgWsProxy_macos_universal.dmg'
return None
def run_update(assets: list, new_version: str) -> bool:
"""Generate a platform updater script and launch it detached. Returns True on success."""
url = find_asset_url(assets)
if not url:
log.warning("Auto-update: no matching asset found for this platform")
return False
if not _can_write_target():
log.warning("Auto-update: no write permission for install target")
return False
pid = os.getpid()
try:
if sys.platform == 'win32':
script = _ps1(pid, sys.executable, url, new_version)
tmp = Path(tempfile.mktemp(suffix='.ps1'))
tmp.write_text(script, encoding='utf-8')
subprocess.Popen(
['powershell', '-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Normal', '-File', str(tmp)],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
elif sys.platform == 'darwin':
app = _find_app_bundle()
if app is None:
log.warning("Auto-update: cannot locate .app bundle")
return False
script = _sh_macos(pid, str(app), url, new_version)
tmp = Path(tempfile.mktemp(suffix='.sh'))
tmp.write_text(script, encoding='utf-8')
tmp.chmod(0o755)
subprocess.Popen(['/bin/bash', str(tmp)], start_new_session=True)
else:
script = _sh_linux(pid, sys.executable, url, new_version)
tmp = Path(tempfile.mktemp(suffix='.sh'))
tmp.write_text(script, encoding='utf-8')
tmp.chmod(0o755)
subprocess.Popen(['/bin/bash', str(tmp)], start_new_session=True)
log.info("Auto-update: updater launched for v%s, shutting down", new_version)
return True
except Exception as exc:
log.error("Auto-update: failed to launch updater: %s", repr(exc))
return False
def _ps1(pid: int, exe: str, url: str, version: str) -> str:
exe_esc = exe.replace("'", "''")
return f"""\
$pid_target = {pid}
$exe = '{exe_esc}'
$url = '{url}'
$ver = '{version}'
Write-Host "TG WS Proxy updater: installing $ver..."
$proc = Get-Process -Id $pid_target -ErrorAction SilentlyContinue
if ($proc) {{
Write-Host "Waiting for process to exit..."
if (-not $proc.WaitForExit(15000)) {{
$proc | Stop-Process -Force
Start-Sleep -Milliseconds 500
}}
}}
$tmp = $exe + '.update'
Write-Host "Downloading..."
try {{
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
(New-Object Net.WebClient).DownloadFile($url, $tmp)
}} catch {{
Write-Host "Download failed: $_"
Start-Sleep 3
exit 1
}}
try {{
Move-Item -LiteralPath $tmp -Destination $exe -Force
}} catch {{
Write-Host "Replace failed: $_"
Start-Sleep 3
exit 1
}}
Write-Host "Done. Restarting..."
Start-Process -FilePath $exe
Start-Sleep -Milliseconds 500
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
"""
def _sh_linux(pid: int, exe: str, url: str, version: str) -> str:
exe_esc = exe.replace("'", "'\\''")
return f"""\
#!/bin/bash
PID={pid}
EXE='{exe_esc}'
URL='{url}'
VER='{version}'
echo "TG WS Proxy updater: installing $VER..."
echo "Waiting for process to exit..."
for i in $(seq 1 30); do
kill -0 $PID 2>/dev/null || break
sleep 0.5
done
kill -9 $PID 2>/dev/null || true
sleep 0.3
TMP="$EXE.update"
echo "Downloading..."
if command -v curl &>/dev/null; then
curl -fsSL -o "$TMP" "$URL" || {{ echo "Download failed"; sleep 3; exit 1; }}
else
wget -q -O "$TMP" "$URL" || {{ echo "Download failed"; sleep 3; exit 1; }}
fi
chmod +x "$TMP"
mv -f "$TMP" "$EXE" || {{ echo "Replace failed"; sleep 3; exit 1; }}
echo "Done. Restarting..."
nohup "$EXE" >/dev/null 2>&1 &
rm -f "$0"
"""
def _sh_macos(pid: int, app_path: str, url: str, version: str) -> str:
app_esc = app_path.replace("'", "'\\''")
return f"""\
#!/bin/bash
PID={pid}
APP='{app_esc}'
URL='{url}'
VER='{version}'
echo "TG WS Proxy updater: installing $VER..."
echo "Waiting for process to exit..."
for i in $(seq 1 30); do
kill -0 $PID 2>/dev/null || break
sleep 0.5
done
kill -9 $PID 2>/dev/null || true
sleep 0.3
TMP_DMG="$(mktemp -t tgwsproxy).dmg"
echo "Downloading..."
if ! curl -fsSL -o "$TMP_DMG" "$URL"; then
echo "Download failed"
rm -f "$TMP_DMG"
sleep 3
exit 1
fi
echo "Mounting..."
MOUNT=$(hdiutil attach -nobrowse -readonly "$TMP_DMG" 2>/dev/null | grep -oE '/Volumes/.*$' | tail -1)
if [ -z "$MOUNT" ]; then
echo "Mount failed"
rm -f "$TMP_DMG"
sleep 3
exit 1
fi
SRC_APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" -type d 2>/dev/null | head -1)
if [ -z "$SRC_APP" ]; then
hdiutil detach -quiet "$MOUNT" 2>/dev/null
rm -f "$TMP_DMG"
echo "No .app found in DMG"
sleep 3
exit 1
fi
echo "Installing..."
BACKUP="$APP.bak"
rm -rf "$BACKUP"
mv -f "$APP" "$BACKUP" 2>/dev/null || true
if ! cp -R "$SRC_APP" "$APP"; then
mv -f "$BACKUP" "$APP" 2>/dev/null || true
hdiutil detach -quiet "$MOUNT" 2>/dev/null
rm -f "$TMP_DMG"
echo "Install failed"
sleep 3
exit 1
fi
rm -rf "$BACKUP"
hdiutil detach -quiet "$MOUNT" 2>/dev/null
rm -f "$TMP_DMG"
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
echo "Done. Restarting..."
open "$APP"
rm -f "$0"
"""

View file

@ -57,6 +57,7 @@ _tray_icon: Optional[object] = None
_config: dict = {}
_exiting = False
_win_mutex_handle = None
_update_version: Optional[str] = None
_ERROR_ALREADY_EXISTS = 183
@ -217,6 +218,28 @@ def _on_open_logs(icon=None, item=None) -> None:
_show_info("Файл логов ещё не создан.")
def _on_update_available(version: str) -> None:
global _update_version
_update_version = version
if _tray_icon:
_tray_icon.menu = _build_menu()
def _on_do_update(icon=None, item=None) -> None:
from utils.updater import run_update
from utils.update_check import get_status
st = get_status()
assets = st.get("assets") or []
ver = st.get("latest") or "?"
if run_update(assets, ver):
_on_exit(_tray_icon)
else:
_show_error(
"Не удалось запустить автообновление.\n"
"Скачайте новую версию вручную на странице релиза."
)
def _on_exit(icon=None, item=None) -> None:
global _exiting
if _exiting:
@ -335,7 +358,13 @@ 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 = []
if _update_version:
items += [
pystray.MenuItem(f"Обновить до {_update_version}", _on_do_update),
pystray.Menu.SEPARATOR,
]
items += [
pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True),
pystray.MenuItem("Скопировать ссылку", _on_copy_link),
pystray.Menu.SEPARATOR,
@ -344,7 +373,8 @@ def _build_menu():
pystray.MenuItem("Открыть логи", _on_open_logs),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Выход", _on_exit),
)
]
return pystray.Menu(*items)
# entry point
@ -370,7 +400,11 @@ def run_tray() -> None:
return
start_proxy(_config, _show_error)
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
maybe_notify_update(
_config, lambda: _exiting, _ask_yes_no,
on_update_available=_on_update_available,
on_exit=lambda: _on_exit(_tray_icon),
)
_show_first_run()
check_ipv6_warning(_show_info)