diff --git a/docs/EN/README.linux.md b/docs/EN/README.linux.md index 3e035f0..78c812b 100644 --- a/docs/EN/README.linux.md +++ b/docs/EN/README.linux.md @@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64 On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required). +## Updating + +When update checks are enabled in settings, the app offers to update itself on startup and installs the new version: + +- installed from `.deb`/`.rpm` — the new package is downloaded and installed by the package manager (via `pkexec`, password confirmation required); +- standalone binary — replaced in place if its directory is writable, otherwise also via `pkexec`. + +The downloaded file is checked against the response length and the SHA-256 digest from the release; a corrupted download is never installed. The app restarts automatically after the update. + +For AUR packages, `pip` installs, and non-x86_64 architectures the previous behaviour applies — the release page is opened. + ## Configuring Telegram Desktop 1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy** diff --git a/docs/RU/README.linux.md b/docs/RU/README.linux.md index 19d7894..a5f8b6a 100644 --- a/docs/RU/README.linux.md +++ b/docs/RU/README.linux.md @@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64 При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). +## Обновление + +Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию: + +- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля); +- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`. + +Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически. + +Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза. + ## Настройка Telegram Desktop 1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** diff --git a/linux.py b/linux.py index 846214d..34c6c16 100644 --- a/linux.py +++ b/linux.py @@ -1,11 +1,14 @@ from __future__ import annotations +import errno import os +import socket import subprocess import sys import threading import time -from typing import Optional +import webbrowser +from typing import Callable, Optional import customtkinter as ctk import pyperclip @@ -15,10 +18,10 @@ from PIL import Image, ImageTk from proxy import get_link_host from utils.tray_common import ( - APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, - acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, - ensure_ctk_thread, ensure_dirs, load_config, load_icon, log, - maybe_notify_update, quit_ctk, release_lock, restart_proxy, + APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, + acquire_lock, bootstrap, check_ipv6_warning, check_update_async, + ctk_run_dialog, ensure_ctk_thread, ensure_dirs, load_config, load_icon, + log, quit_ctk, release_lock, restart_proxy, save_config, start_proxy, stop_proxy, tg_proxy_url, ) from ui.ctk_tray_ui import ( @@ -215,6 +218,87 @@ def _show_first_run() -> None: ctk_run_dialog(_build) +# update + + +def _wait_port_free(host: str, port: int, timeout: float = 5.0) -> bool: + # the successor binds the same port, so don't hand it over too early + deadline = time.monotonic() + timeout + while True: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + return True + except OSError as exc: + if exc.errno != errno.EADDRINUSE: + return True # some other error: waiting will not help + finally: + sock.close() + if time.monotonic() >= deadline: + log.warning("Port %s:%s still busy before restart", host, port) + return False + time.sleep(0.2) + + +def _restart_after_update() -> None: + # the old process must release the lock and the port before the new one starts + from utils.linux_update import exe_path, relaunch_env + + release_lock() + stop_proxy() + _wait_port_free( + _config.get("host", DEFAULT_CONFIG["host"]), + int(_config.get("port", DEFAULT_CONFIG["port"])), + ) + try: + subprocess.Popen( + [str(exe_path())], env=relaunch_env(), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + except OSError as exc: + log.error("Failed to launch updated binary: %s", exc) + time.sleep(0.5) + os._exit(0) + + +def _maybe_do_update(cfg: dict, is_exiting: Callable[[], bool]) -> None: + def _on_update(version: str, page_url: str) -> None: + from utils.linux_update import asset_name, detect_install_kind + from utils.update_check import find_asset + + kind = detect_install_kind() if IS_FROZEN else None + asset = find_asset(asset_name(kind)) if kind else None + if not asset: + log.info("In-place update unavailable (kind=%s)", kind) + if _ask_yes_no(t("update.ask_open", version=version), t("app.update_title")): + webbrowser.open(page_url) + return + + if not _ask_yes_no(t("update.ask_install", version=version), t("app.update_title")): + return + _install_update(kind, asset, page_url) + + check_update_async(cfg, is_exiting, _on_update) + + +def _install_update(kind: str, asset: dict, page_url: str) -> None: + from utils.linux_update import apply_update + + err = apply_update( + kind, asset["url"], digest=asset.get("digest", ""), log=log, + ) + if err is None: + log.info("Update installed, restarting") + _restart_after_update() + return + + log.error("Update failed: %s", err) + if _ask_yes_no(t("update.install_fail", error=err), t("app.update_title")): + webbrowser.open(page_url) + + # tray menu @@ -254,7 +338,7 @@ def run_tray() -> None: return start_proxy(_config, _show_error) - maybe_notify_update(_config, lambda: _exiting, _ask_yes_no) + _maybe_do_update(_config, lambda: _exiting) _show_first_run() check_ipv6_warning(_show_info) diff --git a/tests/test_linux_update.py b/tests/test_linux_update.py new file mode 100644 index 0000000..7f5eeb2 --- /dev/null +++ b/tests/test_linux_update.py @@ -0,0 +1,241 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from utils import linux_update + + +class _Proc: + def __init__(self, returncode, stdout=b'', stderr=b''): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class AssetNameTest(unittest.TestCase): + def test_names_match_release_assets(self): + self.assertEqual(linux_update.asset_name('binary'), 'TgWsProxy_linux_amd64') + self.assertEqual(linux_update.asset_name('deb'), 'TgWsProxy_linux_amd64.deb') + self.assertEqual(linux_update.asset_name('rpm'), 'TgWsProxy_linux_amd64.rpm') + + +class DetectInstallKindTest(unittest.TestCase): + def _detect(self, *, machine='x86_64', tools=(), owned_by=None, writable=True): + exe = Path('/usr/bin/tg-ws-proxy') + + def _which(name): + return '/usr/bin/' + name if name in tools else None + + def _run_quiet(cmd): + return cmd[0] == owned_by + + with mock.patch.object(linux_update.platform, 'machine', return_value=machine), \ + mock.patch.object(linux_update.shutil, 'which', side_effect=_which), \ + mock.patch.object(linux_update, '_run_quiet', side_effect=_run_quiet), \ + mock.patch.object(linux_update.os, 'access', return_value=writable), \ + mock.patch.object(linux_update, '_is_root', return_value=False): + return linux_update.detect_install_kind(exe) + + def test_deb_package_with_pkexec(self): + self.assertEqual( + self._detect(tools=('dpkg-query', 'pkexec', 'apt-get'), owned_by='dpkg-query'), + 'deb', + ) + + def test_rpm_package_with_pkexec(self): + self.assertEqual( + self._detect(tools=('rpm', 'pkexec'), owned_by='rpm'), 'rpm', + ) + + def test_package_without_pkexec_is_unsupported(self): + self.assertIsNone(self._detect(tools=('dpkg-query',), owned_by='dpkg-query')) + + def test_aur_install_is_unsupported(self): + self.assertIsNone( + self._detect(tools=('pacman', 'pkexec'), owned_by='pacman'), + ) + + def test_standalone_writable_binary(self): + self.assertEqual(self._detect(tools=()), 'binary') + + def test_read_only_dir_is_supported_via_pkexec(self): + self.assertEqual(self._detect(tools=('pkexec',), writable=False), 'binary') + + def test_read_only_dir_without_pkexec_is_unsupported(self): + self.assertIsNone(self._detect(tools=(), writable=False)) + + def test_non_amd64_is_unsupported(self): + self.assertIsNone(self._detect(machine='aarch64')) + + +class InstallCmdTest(unittest.TestCase): + def _cmd(self, kind, tools): + with mock.patch.object( + linux_update.shutil, 'which', + side_effect=lambda n: '/usr/bin/' + n if n in tools else None, + ), mock.patch.object(linux_update, '_is_root', return_value=False): + return linux_update.install_cmd(kind, Path('/tmp/pkg')) + + def test_deb_prefers_apt_get_and_elevates(self): + cmd = self._cmd('deb', ('apt-get',)) + self.assertEqual(cmd[:3], ['pkexec', 'apt-get', 'install']) + self.assertEqual(cmd[-1], '/tmp/pkg') + + def test_deb_falls_back_to_dpkg(self): + self.assertEqual(self._cmd('deb', ()), ['pkexec', 'dpkg', '-i', '/tmp/pkg']) + + def test_rpm_prefers_dnf_without_removing_packages(self): + cmd = self._cmd('rpm', ('dnf',)) + self.assertEqual(cmd, ['pkexec', 'dnf', 'install', '-y', '/tmp/pkg']) + self.assertNotIn('--allowerasing', cmd) + + def test_rpm_falls_back_to_rpm_binary(self): + self.assertEqual( + self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', '--force', '/tmp/pkg'], + ) + + def test_unknown_kind_is_rejected(self): + with self.assertRaises(ValueError): + self._cmd('binary', ('apt-get',)) + + def test_root_does_not_use_pkexec(self): + with mock.patch.object(linux_update.shutil, 'which', return_value=None), \ + mock.patch.object(linux_update, '_is_root', return_value=True): + self.assertEqual( + linux_update.install_cmd('deb', Path('/tmp/pkg')), + ['dpkg', '-i', '/tmp/pkg'], + ) + + +class ApplyUpdateTest(unittest.TestCase): + def _download(self, payload=b'new'): + def _fn(url, dest_dir, suffix='.tmp', digest=''): + path = Path(dest_dir) / ('new' + suffix) + path.write_bytes(payload) + return path + return _fn + + def test_writable_binary_is_replaced_in_place(self): + with tempfile.TemporaryDirectory() as tmp: + exe = Path(tmp) / 'TgWsProxy' + exe.write_bytes(b'old') + + with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ + mock.patch('utils.update_check.download_asset', self._download()): + self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a')) + + self.assertEqual(exe.read_bytes(), b'new') + self.assertTrue(os.access(str(exe), os.X_OK)) + self.assertEqual(list(Path(tmp).iterdir()), [exe]) + + def test_read_only_dir_binary_is_installed_with_elevation(self): + exe = Path('/usr/local/bin/TgWsProxy') + with tempfile.TemporaryDirectory() as tmp: + def _download(url, dest_dir, suffix='.tmp', digest=''): + path = Path(tmp) / ('new' + suffix) + path.write_bytes(b'new') + return path + + run = mock.Mock(return_value=_Proc(0)) + with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ + mock.patch.object(linux_update.os, 'access', return_value=False), \ + mock.patch.object(linux_update, '_is_root', return_value=False), \ + mock.patch('utils.update_check.download_asset', _download), \ + mock.patch.object(linux_update.subprocess, 'run', run): + self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a')) + + cmd = run.call_args[0][0] + self.assertEqual(cmd[:4], ['pkexec', 'install', '-m', '755']) + self.assertEqual(cmd[-1], str(exe)) + + def test_deb_install_failure_returns_stderr(self): + run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error')) + with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \ + mock.patch('utils.update_check.download_asset', self._download()), \ + mock.patch.object(linux_update.subprocess, 'run', run), \ + mock.patch.object(linux_update.shutil, 'which', return_value=None), \ + mock.patch.object(linux_update, '_is_root', return_value=True): + err = linux_update.apply_update('deb', 'https://x.invalid/a.deb') + + self.assertEqual(err, 'dpkg: error') + + def test_deb_install_success_removes_downloaded_package(self): + holder = {} + + def _download(url, dest_dir, suffix='.tmp', digest=''): + path = Path(dest_dir) / ('pkg' + suffix) + path.write_bytes(b'deb') + holder['path'] = path + return path + + with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \ + mock.patch('utils.update_check.download_asset', _download), \ + mock.patch.object(linux_update.subprocess, 'run', + return_value=_Proc(0)), \ + mock.patch.object(linux_update.shutil, 'which', return_value=None), \ + mock.patch.object(linux_update, '_is_root', return_value=True): + self.assertIsNone(linux_update.apply_update('deb', 'https://x.invalid/a.deb')) + + self.assertFalse(holder['path'].exists()) + + def test_digest_is_passed_to_the_downloader(self): + seen = {} + + def _download(url, dest_dir, suffix='.tmp', digest=''): + seen['digest'] = digest + path = Path(dest_dir) / ('new' + suffix) + path.write_bytes(b'new') + return path + + with tempfile.TemporaryDirectory() as tmp: + exe = Path(tmp) / 'TgWsProxy' + exe.write_bytes(b'old') + with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ + mock.patch('utils.update_check.download_asset', _download): + linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab') + + self.assertEqual(seen['digest'], 'sha256:ab') + + def test_download_failure_is_reported(self): + with mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \ + mock.patch('utils.update_check.download_asset', + side_effect=OSError('boom')): + self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), 'boom') + + +class RelaunchEnvTest(unittest.TestCase): + def _env(self, env, meipass='/tmp/_MEI1'): + with mock.patch.dict(linux_update.os.environ, env, clear=True), \ + mock.patch.object(linux_update.sys, '_MEIPASS', meipass, create=True): + return linux_update.relaunch_env() + + def test_bundle_paths_are_dropped_and_originals_restored(self): + out = self._env({ + '_PYI_APPLICATION_HOME_DIR': '/tmp/_MEI1', + '_MEIPASS': '/tmp/_MEI1', + 'LD_LIBRARY_PATH': '/tmp/_MEI1', + 'LD_LIBRARY_PATH_ORIG': '/usr/lib', + 'GI_TYPELIB_PATH': '/tmp/_MEI1', + 'PATH': '/tmp/_MEI1:/usr/bin', + 'HOME': '/home/u', + }) + + self.assertEqual(out['LD_LIBRARY_PATH'], '/usr/lib') + self.assertNotIn('GI_TYPELIB_PATH', out) + self.assertEqual(out['PATH'], '/usr/bin') + self.assertNotIn('_MEIPASS', out) + self.assertNotIn('_PYI_APPLICATION_HOME_DIR', out) + self.assertEqual(out['HOME'], '/home/u') + + def test_environment_without_bundle_is_unchanged(self): + env = {'PATH': '/usr/bin', 'HOME': '/home/u'} + with mock.patch.dict(linux_update.os.environ, env, clear=True): + with mock.patch.object(linux_update.sys, '_MEIPASS', None, create=True): + out = linux_update.relaunch_env() + self.assertEqual(out, env) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tray_common.py b/tests/test_tray_common.py new file mode 100644 index 0000000..33ccdf3 --- /dev/null +++ b/tests/test_tray_common.py @@ -0,0 +1,49 @@ +import unittest +from unittest import mock + +from utils import tray_common + + +class CheckUpdateAsyncTest(unittest.TestCase): + def test_disabled_check_does_not_start_a_thread(self): + called = [] + with mock.patch.object(tray_common.threading, 'Thread') as thread: + tray_common.check_update_async( + {'check_updates': False}, lambda: False, lambda v, u: called.append(v), + ) + thread.assert_not_called() + self.assertEqual(called, []) + + def test_update_is_reported_to_the_handler(self): + status = {'has_update': True, 'latest': '2.0.0', 'html_url': 'https://x.invalid/r'} + seen = [] + with mock.patch.object(tray_common.threading, 'Thread') as thread, \ + mock.patch.object(tray_common.time, 'sleep'), \ + mock.patch('utils.update_check.run_check'), \ + mock.patch('utils.update_check.get_status', return_value=status): + tray_common.check_update_async( + {'check_updates': True}, lambda: False, + lambda version, url: seen.append((version, url)), + ) + thread.call_args.kwargs['target']() + + self.assertEqual(seen, [('2.0.0', 'https://x.invalid/r')]) + + def test_no_update_means_no_handler_call(self): + seen = [] + with mock.patch.object(tray_common.threading, 'Thread') as thread, \ + mock.patch.object(tray_common.time, 'sleep'), \ + mock.patch('utils.update_check.run_check'), \ + mock.patch('utils.update_check.get_status', + return_value={'has_update': False}): + tray_common.check_update_async( + {'check_updates': True}, lambda: False, + lambda version, url: seen.append(version), + ) + thread.call_args.kwargs['target']() + + self.assertEqual(seen, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 71bf8c0..1a708cb 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -1,5 +1,11 @@ +import hashlib +import io +import tempfile import unittest +from pathlib import Path +from unittest import mock +from utils import update_check from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt @@ -66,5 +72,92 @@ class ExtractAssetsTest(unittest.TestCase): self.assertEqual(_extract_assets({}), []) +class _Response(io.BytesIO): + def __init__(self, body, content_length='auto'): + super().__init__(body) + if content_length == 'auto': + content_length = len(body) + self.headers = {} if content_length is None else { + 'Content-Length': str(content_length), + } + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + +def _opener(response): + opener = mock.Mock() + opener.open.return_value = response + return mock.Mock(return_value=opener) + + +class DownloadAssetTest(unittest.TestCase): + def _download(self, response, digest=''): + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(update_check, 'build_github_opener', _opener(response)): + path = update_check.download_asset( + 'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest, + ) + try: + return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir()) + finally: + path.unlink(missing_ok=True) + + def test_complete_download_is_kept(self): + body, files = self._download(_Response(b'payload')) + self.assertEqual(body, b'payload') + self.assertEqual(len(files), 1) + + def test_matching_digest_is_accepted(self): + digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest() + body, _ = self._download(_Response(b'payload'), digest=digest) + self.assertEqual(body, b'payload') + + def test_unknown_digest_format_is_ignored(self): + body, _ = self._download(_Response(b'payload'), digest='md5:whatever') + self.assertEqual(body, b'payload') + + def test_truncated_download_raises_and_leaves_no_file(self): + with tempfile.TemporaryDirectory() as tmp: + resp = _Response(b'half', content_length=99) + with mock.patch.object(update_check, 'build_github_opener', _opener(resp)): + with self.assertRaises(OSError): + update_check.download_asset( + 'https://example.invalid/a', Path(tmp), suffix='.bin', + ) + self.assertEqual(list(Path(tmp).iterdir()), []) + + def test_digest_mismatch_raises_and_leaves_no_file(self): + with tempfile.TemporaryDirectory() as tmp: + resp = _Response(b'payload') + with mock.patch.object(update_check, 'build_github_opener', _opener(resp)): + with self.assertRaises(OSError): + update_check.download_asset( + 'https://example.invalid/a', Path(tmp), + suffix='.bin', digest='sha256:' + '0' * 64, + ) + self.assertEqual(list(Path(tmp).iterdir()), []) + + def test_missing_content_length_is_tolerated(self): + body, _ = self._download(_Response(b'payload', content_length=None)) + self.assertEqual(body, b'payload') + + +class FindAssetTest(unittest.TestCase): + def test_returns_asset_with_digest(self): + assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': 'sha256:ab'}] + with mock.patch.dict(update_check._state, {'assets': assets}): + self.assertEqual(update_check.find_asset('a.deb'), assets[0]) + self.assertIsNone(update_check.find_asset('b.deb')) + + def test_ignores_asset_without_url(self): + with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}): + self.assertIsNone(update_check.find_asset('a.deb')) + + if __name__ == '__main__': unittest.main() diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 3ef5859..7e5c758 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -137,6 +137,8 @@ "update.available": "New version available: {version}", "update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?", + "update.ask_install": "New version available: {version}\n\nUpdate now? The app will restart.", + "update.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?", "update.downloading": "Downloading...", "update.replacing": "Replacing file...", "update.restarting": "Restarting...", diff --git a/ui/i18n/ru.json b/ui/i18n/ru.json index bf1ad67..7892b03 100644 --- a/ui/i18n/ru.json +++ b/ui/i18n/ru.json @@ -137,6 +137,8 @@ "update.available": "Доступна новая версия: {version}", "update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?", + "update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.", + "update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?", "update.downloading": "Скачивание...", "update.replacing": "Замена файла...", "update.restarting": "Перезапуск...", diff --git a/utils/linux_update.py b/utils/linux_update.py new file mode 100644 index 0000000..f92bd43 --- /dev/null +++ b/utils/linux_update.py @@ -0,0 +1,180 @@ +""" +In-place update on Linux. + +Detects how the app is installed (deb/rpm package or a standalone binary) +and updates it without a manual download: packages go through the package +manager, a standalone binary is replaced with the release file. Without +write access the install runs through pkexec. Unsupported setups (AUR, pip, +non-amd64, no pkexec) yield None and the caller shows the release page. +""" +from __future__ import annotations + +import logging +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Dict, List, Optional + +BINARY_ASSET = "TgWsProxy_linux_amd64" + +# install kind -> release asset suffix +ASSET_SUFFIX = {"binary": "", "deb": ".deb", "rpm": ".rpm"} + +_INSTALL_TIMEOUT_SEC = 600.0 + + +def _run_quiet(cmd: List[str]) -> bool: + try: + return subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def exe_path() -> Path: + return Path(os.path.realpath(sys.executable)) + + +def _is_root() -> bool: + return hasattr(os, "geteuid") and os.geteuid() == 0 + + +def _can_elevate() -> bool: + return _is_root() or bool(shutil.which("pkexec")) + + +def _elevate() -> List[str]: + return [] if _is_root() else ["pkexec"] + + +def detect_install_kind(exe: Optional[Path] = None) -> Optional[str]: + """How the app is installed: "deb", "rpm", "binary" or None.""" + exe = exe or exe_path() + if platform.machine().lower() not in ("x86_64", "amd64"): + return None # releases ship amd64 only + if shutil.which("pacman") and _run_quiet(["pacman", "-Qo", str(exe)]): + return None # AUR install, updated by its own package manager + + can_elevate = _can_elevate() + if shutil.which("dpkg-query") and _run_quiet(["dpkg-query", "-S", str(exe)]): + return "deb" if can_elevate else None + if shutil.which("rpm") and _run_quiet(["rpm", "-qf", str(exe)]): + return "rpm" if can_elevate else None + + # replacing the binary needs a writable directory or root + if os.access(str(exe.parent), os.W_OK) or can_elevate: + return "binary" + return None + + +def asset_name(kind: str) -> str: + return BINARY_ASSET + ASSET_SUFFIX[kind] + + +def install_cmd(kind: str, path: Path) -> List[str]: + """Command that installs the downloaded package.""" + elevate = _elevate() + if kind == "deb": + if shutil.which("apt-get"): + return elevate + [ + "apt-get", "install", "-y", "--reinstall", + "--allow-downgrades", str(path), + ] + return elevate + ["dpkg", "-i", str(path)] + if kind == "rpm": + if shutil.which("dnf"): + return elevate + ["dnf", "install", "-y", str(path)] + return elevate + ["rpm", "-U", "--force", str(path)] + raise ValueError("no install command for kind: {0}".format(kind)) + + +def replace_binary_cmd(src: Path, exe: Path) -> List[str]: + """Replace the binary as root; install(1) avoids ETXTBSY on the running file.""" + return _elevate() + ["install", "-m", "755", str(src), str(exe)] + + +def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]: + if log: + log.info("Linux update: %s", " ".join(cmd)) + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=_INSTALL_TIMEOUT_SEC, + ) + if proc.returncode == 0: + return None + err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip() + return err[-500:] or "exit code {0}".format(proc.returncode) + + +def apply_update( + kind: str, url: str, digest: str = "", + log: Optional[logging.Logger] = None, +) -> Optional[str]: + """Download and install the update. + + Returns: + Error text, or None on success. + """ + from utils.update_check import download_asset + + exe = exe_path() + suffix = ASSET_SUFFIX[kind] or ".new" + # with a writable directory download next to the binary and os.replace it + in_place = kind == "binary" and os.access(str(exe.parent), os.W_OK) + dest_dir = exe.parent if in_place else Path(tempfile.gettempdir()) + + if log: + log.info("Linux update (%s): downloading %s", kind, url) + try: + tmp = download_asset(url, dest_dir, suffix=suffix, digest=digest) + except Exception as exc: + return str(exc) + + try: + if kind != "binary": + return _run_install(install_cmd(kind, tmp), log=log) + os.chmod(str(tmp), 0o755) + if in_place: + os.replace(str(tmp), str(exe)) + return None + return _run_install(replace_binary_cmd(tmp, exe), log=log) + except (OSError, ValueError, subprocess.SubprocessError) as exc: + return str(exc) + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def relaunch_env() -> Dict[str, str]: + """Environment for relaunching without the unpacked PyInstaller bundle.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("_PYI_")} + env.pop("_MEIPASS", None) + mei = getattr(sys, "_MEIPASS", None) + for var in ("PATH", "LD_LIBRARY_PATH", "GI_TYPELIB_PATH", "GTK_PATH", + "GST_PLUGIN_PATH", "GIO_MODULE_DIR"): + original = env.pop(var + "_ORIG", None) + if original is not None: + env[var] = original + continue + if not mei or not env.get(var): + continue + target = os.path.normcase(mei.rstrip("\\/")) + kept = [ + p for p in env[var].split(os.pathsep) + if os.path.normcase(p.rstrip("\\/")) != target + ] + if kept: + env[var] = os.pathsep.join(kept) + else: + env.pop(var, None) + return env diff --git a/utils/tray_common.py b/utils/tray_common.py index 2c7842c..d5171e7 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -460,41 +460,57 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None: # update check -def maybe_notify_update( +def check_update_async( cfg: dict, is_exiting: Callable[[], bool], - ask_open: Callable[[str, str], bool], + on_update: Callable[[str, str], None], ) -> None: + """Check the latest release in the background. + + on_update(version, release_page_url) is called from the worker thread + when a newer version is available. + """ if not cfg.get("check_updates", True): return - def _work(): + def _work() -> None: time.sleep(1.5) if is_exiting(): return try: from utils.update_check import RELEASES_PAGE_URL, get_status, run_check - import webbrowser run_check(__version__) st = get_status() - if not st.get("has_update"): + if not st.get("has_update") or is_exiting(): return - url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - ver = st.get("latest") or "?" - from ui.i18n import t - - if ask_open( - t("update.ask_open", version=ver), - t("app.update_title"), - ): - webbrowser.open(url) + on_update( + st.get("latest") or "?", + (st.get("html_url") or "").strip() or RELEASES_PAGE_URL, + ) except Exception as exc: log.warning("Update check failed: %s", repr(exc)) threading.Thread(target=_work, daemon=True, name="update-check").start() +def maybe_notify_update( + cfg: dict, + is_exiting: Callable[[], bool], + ask_open: Callable[[str, str], bool], +) -> None: + """Check for updates and offer to open the release page.""" + def _on_update(version: str, url: str) -> None: + import webbrowser + + from ui.i18n import t + + if ask_open(t("update.ask_open", version=version), t("app.update_title")): + webbrowser.open(url) + + check_update_async(cfg, is_exiting, _on_update) + + # ctk thread (windows / linux) _ctk_root: Any = None diff --git a/utils/update_check.py b/utils/update_check.py index 4e8e3b2..08baa73 100644 --- a/utils/update_check.py +++ b/utils/update_check.py @@ -6,8 +6,11 @@ """ from __future__ import annotations +import hashlib import json +import os import sys +import tempfile import time from itertools import zip_longest from pathlib import Path @@ -259,6 +262,65 @@ def _extract_assets(data: Optional[dict]) -> list: ] +def _expected_length(resp: Any) -> Optional[int]: + try: + return int(resp.headers.get("Content-Length")) + except (AttributeError, TypeError, ValueError): + return None + + +def download_asset( + url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "", +) -> Path: + """Download a release asset into a temporary file inside dest_dir. + + The size is checked against Content-Length and, when the GitHub API + provides a "sha256:..." digest, the checksum too. A truncated or + corrupted file is removed instead of being returned. + + Raises: + OSError: network error, truncated download or checksum mismatch. + """ + fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix) + os.close(fd) + path = Path(name) + try: + sha = hashlib.sha256() + written = 0 + with build_github_opener().open(url) as resp: + expected = _expected_length(resp) + with open(str(path), "wb") as out: + while True: + chunk = resp.read(65536) + if not chunk: + break + out.write(chunk) + sha.update(chunk) + written += len(chunk) + if expected is not None and written != expected: + raise OSError( + "incomplete download: {0} of {1} bytes".format(written, expected) + ) + want = (digest or "").lower() + if want.startswith("sha256:") and sha.hexdigest() != want[7:]: + raise OSError("checksum does not match the release asset") + return path + except BaseException: + try: + path.unlink(missing_ok=True) + except OSError: + pass + raise + + +def find_asset(name: str) -> Optional[Dict[str, str]]: + """Asset of the latest release by name (after run_check).""" + for a in _state.get("assets") or []: + if a.get("name") == name and a.get("url"): + return a + return None + + def get_status() -> Dict[str, Any]: """Снимок состояния после run_check (для подписей в настройках).""" return dict(_state) @@ -273,7 +335,6 @@ def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str # SHA256 match try: - import hashlib data, code = fetch_release_by_tag(f"v{current_version}") if code == 200 and data: cur_assets = _extract_assets(data)