diff --git a/linux.py b/linux.py index 45b6eda..5533f84 100644 --- a/linux.py +++ b/linux.py @@ -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) diff --git a/macos.py b/macos.py index 3b1915d..b34440a 100644 --- a/macos.py +++ b/macos.py @@ -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() diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index eb80822..96858d6 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -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: diff --git a/utils/default_config.py b/utils/default_config.py index 8e0dc5b..e5d9561 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -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, diff --git a/utils/tray_common.py b/utils/tray_common.py index 3e3c70f..9eba1a4 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -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 — обновление", diff --git a/utils/update_check.py b/utils/update_check.py index 026dd41..2e63bde 100644 --- a/utils/update_check.py +++ b/utils/update_check.py @@ -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: diff --git a/utils/updater.py b/utils/updater.py new file mode 100644 index 0000000..f65730c --- /dev/null +++ b/utils/updater.py @@ -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" +""" diff --git a/windows.py b/windows.py index e3b5ecb..d66f4c2 100644 --- a/windows.py +++ b/windows.py @@ -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)