From 03be9baad1454fb84bc8037be88c5bfe79189a75 Mon Sep 17 00:00:00 2001 From: Flowseal Date: Sun, 11 Oct 2026 15:55:00 +0300 Subject: [PATCH] Updates refactoring --- linux.py | 98 ++++----- tests/test_linux_update.py | 151 +++++++++++-- tests/test_update_check.py | 188 +++++++++++++++- tests/test_windows_update.py | 169 ++++++++++++++ ui/i18n/en.json | 4 - ui/i18n/ru.json | 4 - utils/linux_update.py | 76 ++----- utils/update_check.py | 416 ++++++++--------------------------- utils/windows_update.py | 54 +++++ windows.py | 155 ++++--------- 10 files changed, 752 insertions(+), 563 deletions(-) create mode 100644 tests/test_windows_update.py create mode 100644 utils/windows_update.py diff --git a/linux.py b/linux.py index 34c6c16..6403b53 100644 --- a/linux.py +++ b/linux.py @@ -1,21 +1,19 @@ from __future__ import annotations -import errno import os -import socket import subprocess import sys import threading import time import webbrowser -from typing import Callable, Optional +from typing import Optional import customtkinter as ctk import pyperclip import pystray from PIL import Image, ImageTk -from proxy import get_link_host +from proxy import __version__, get_link_host from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, @@ -34,10 +32,12 @@ from ui.ctk_theme import ( ) from ui.i18n import t from ui.settings_dialog import SettingsDialog +from utils.update_check import get_status, relaunch_env _tray_icon: Optional[object] = None _config: dict = {} _exiting = False +_update_flow_lock = threading.Lock() # dialogs (tkinter messagebox) @@ -164,6 +164,7 @@ def _edit_config_dialog() -> None: widgets = install_tray_config_form( ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False, + on_update_click=_trigger_update_flow, ) def _finish() -> None: @@ -221,66 +222,57 @@ def _show_first_run() -> None: # 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 + from utils.linux_update import exe_path - release_lock() stop_proxy() - _wait_port_free( - _config.get("host", DEFAULT_CONFIG["host"]), - int(_config.get("port", DEFAULT_CONFIG["port"])), - ) + release_lock() + exe = str(exe_path()) try: subprocess.Popen( - [str(exe_path())], env=relaunch_env(), + [exe, *sys.argv[1:]], 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) + if acquire_lock(): + start_proxy(_config, _show_error) + _show_error(t("update.error", msg=exc)) + return os._exit(0) -def _maybe_do_update(cfg: dict, is_exiting: Callable[[], bool]) -> None: - def _on_update(version: str, page_url: str) -> None: +def _trigger_update_flow(icon=None, item=None) -> None: + if not _update_flow_lock.acquire(blocking=False): + return + + def _work() -> None: from utils.linux_update import asset_name, detect_install_kind - from utils.update_check import find_asset + from utils.update_check import find_asset, RELEASES_PAGE_URL - 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 + try: + st = get_status() + if _exiting or not st["has_update"]: + return + if _tray_icon is not None: + _tray_icon.menu = _build_menu() + kind = detect_install_kind() if IS_FROZEN else None + asset = find_asset(asset_name(kind)) if kind else None + page_url = st["html_url"] or RELEASES_PAGE_URL + if not asset: + if _ask_yes_no(t("update.ask_open", version=st["latest"]), 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) + if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")): + _install_update(kind, asset, page_url) + except Exception: + log.exception("Update flow failed") + finally: + _update_flow_lock.release() - check_update_async(cfg, is_exiting, _on_update) + threading.Thread(target=_work, daemon=True, name="manual-update").start() def _install_update(kind: str, asset: dict, page_url: str) -> None: @@ -306,7 +298,7 @@ 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 = [ 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.Menu.SEPARATOR, @@ -315,7 +307,13 @@ def _build_menu(): pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.Menu.SEPARATOR, pystray.MenuItem(t("tray.exit"), _on_exit), - ) + ] + st = get_status() + if st["has_update"]: + items.insert(-2, pystray.MenuItem( + t("tray.update", current=__version__, new=st["latest"]), _trigger_update_flow, + )) + return pystray.Menu(*items) # entry point @@ -338,7 +336,7 @@ def run_tray() -> None: return start_proxy(_config, _show_error) - _maybe_do_update(_config, lambda: _exiting) + check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow()) _show_first_run() check_ipv6_warning(_show_info) diff --git a/tests/test_linux_update.py b/tests/test_linux_update.py index cc9d4f2..fc7d0ff 100644 --- a/tests/test_linux_update.py +++ b/tests/test_linux_update.py @@ -1,12 +1,18 @@ +import hashlib +import io import os +import shutil import stat +import subprocess import sys import tempfile import unittest +from contextlib import ExitStack +from http.client import IncompleteRead from pathlib import Path from unittest import mock -from utils import linux_update +from utils import linux_update, update_check class _Proc: @@ -97,7 +103,7 @@ class InstallCmdTest(unittest.TestCase): def test_rpm_falls_back_to_rpm_binary(self): self.assertEqual( - self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', '--force', str(self.package_path)], + self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)], ) def test_unknown_kind_is_rejected(self): @@ -127,7 +133,7 @@ class ApplyUpdateTest(unittest.TestCase): 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()): + mock.patch('utils.linux_update.download_asset', self._download()): self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a')) self.assertEqual(exe.read_bytes(), b'new') @@ -139,7 +145,7 @@ class ApplyUpdateTest(unittest.TestCase): 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()): + mock.patch('utils.linux_update.download_asset', self._download()): self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a')) self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755) @@ -155,18 +161,18 @@ class ApplyUpdateTest(unittest.TestCase): 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('utils.linux_update.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[:3], ['pkexec', 'sh', '-c']) 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('utils.linux_update.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): @@ -184,7 +190,7 @@ class ApplyUpdateTest(unittest.TestCase): 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('utils.linux_update.download_asset', _download), \ mock.patch.object(linux_update.subprocess, 'run', return_value=_Proc(0)), \ mock.patch.object(linux_update.shutil, 'which', return_value=None), \ @@ -206,16 +212,99 @@ class ApplyUpdateTest(unittest.TestCase): 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): + mock.patch('utils.linux_update.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') + for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)): + with self.subTest(error=error), \ + mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \ + mock.patch.object(linux_update, 'download_asset', side_effect=error), \ + mock.patch.object(linux_update, '_run_install') as install: + self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error)) + install.assert_not_called() + + def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self): + for kind in ('binary', 'deb', 'rpm'): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp: + exe = Path(tmp) / 'TgWsProxy' + exe.write_bytes(b'old') + opener = mock.Mock() + opener.open.return_value = io.BytesIO(b'corrupt') + with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ + mock.patch.object(update_check, 'build_github_opener', return_value=opener), \ + mock.patch.object(linux_update, '_run_install') as install: + error = linux_update.apply_update(kind, 'https://example.invalid/asset', + digest='sha256:' + '0' * 64) + self.assertIn('SHA-256', error) + install.assert_not_called() + self.assertEqual(exe.read_bytes(), b'old') + self.assertEqual(list(Path(tmp).iterdir()), [exe]) + + def test_verified_download_is_installed(self): + with tempfile.TemporaryDirectory() as tmp: + exe = Path(tmp) / 'TgWsProxy' + exe.write_bytes(b'old') + opener = mock.Mock() + opener.open.return_value = io.BytesIO(b'new') + with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ + mock.patch.object(update_check, 'build_github_opener', return_value=opener): + self.assertIsNone(linux_update.apply_update( + 'binary', 'https://example.invalid/asset', + digest='sha256:' + hashlib.sha256(b'new').hexdigest(), + )) + self.assertEqual(exe.read_bytes(), b'new') + self.assertEqual(list(Path(tmp).iterdir()), [exe]) + + def test_install_timeout_cleans_download_directory(self): + holder = {} + + def download(url, dest_dir, **kwargs): + holder['directory'] = dest_dir + path = dest_dir / 'update.deb' + path.write_bytes(b'package') + return path + + with mock.patch.object(linux_update, 'download_asset', side_effect=download), \ + mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)): + self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset')) + self.assertFalse(holder['directory'].exists()) + + +@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement') +class ElevatedBinaryReplacementTest(unittest.TestCase): + def test_replaces_a_running_executable_with_special_characters_in_its_path(self): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + exe = directory / 'app $(touch unwanted); тест' + src = directory / 'download with spaces' + shutil.copy2(shutil.which('sleep'), exe) + src.write_bytes(b'new') + process = subprocess.Popen([str(exe), '30']) + try: + with mock.patch.object(linux_update, '_is_root', return_value=True): + cmd = linux_update.replace_binary_cmd(src, exe) + self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0) + self.assertIsNone(process.poll()) + self.assertEqual(exe.read_bytes(), b'new') + self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755) + self.assertEqual(set(directory.iterdir()), {exe, src}) + finally: + process.terminate() + process.wait(timeout=5) + + def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self): + with tempfile.TemporaryDirectory() as tmp: + exe = Path(tmp) / 'app' + exe.write_bytes(b'old') + with mock.patch.object(linux_update, '_is_root', return_value=True): + cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe) + result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(exe.read_bytes(), b'old') + self.assertEqual(list(Path(tmp).iterdir()), [exe]) class ExternalCommandsEnvTest(unittest.TestCase): @@ -276,5 +365,41 @@ class RelaunchEnvTest(unittest.TestCase): self.assertEqual(out, env) +class RestartAfterUpdateTest(unittest.TestCase): + def test_restart_releases_resources_before_launch_and_preserves_arguments(self): + with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}): + import linux + + events = [] + exe = str(Path(tempfile.gettempdir()) / 'proxy') + with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \ + mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \ + mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \ + mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \ + mock.patch.object(sys, 'argv', ['proxy', '--portable']), \ + mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \ + mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')): + linux._restart_after_update() + self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit']) + self.assertEqual(launch.call_args[0][0], [exe, '--portable']) + self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'}) + + def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self): + with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}): + import linux + + with ExitStack() as stack: + mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in ( + 'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error', + )} + exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit')) + stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed'))) + linux._restart_after_update() + mocks['acquire_lock'].assert_called_once_with() + mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error']) + mocks['_show_error'].assert_called_once() + exit_process.assert_not_called() + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 1a708cb..2ce1a89 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -1,13 +1,22 @@ import hashlib import io +import json +import os +import sys import tempfile +import threading import unittest +from http.client import IncompleteRead from pathlib import Path from unittest import mock +from urllib.error import HTTPError, URLError from utils import update_check from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt +PAYLOAD = b'payload' +DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest() + class ParseVersionTupleTest(unittest.TestCase): def test_plain_and_prefixed_versions(self): @@ -96,7 +105,7 @@ def _opener(response): class DownloadAssetTest(unittest.TestCase): - def _download(self, response, digest=''): + def _download(self, response, digest=DIGEST): with tempfile.TemporaryDirectory() as tmp: with mock.patch.object(update_check, 'build_github_opener', _opener(response)): path = update_check.download_asset( @@ -117,17 +126,22 @@ class DownloadAssetTest(unittest.TestCase): 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_invalid_or_missing_digest_is_rejected_before_any_download(self): + for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64): + with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(update_check, 'build_github_opener') as opener: + with self.assertRaises(ValueError): + update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest) + opener.assert_not_called() + self.assertEqual(list(Path(tmp).iterdir()), []) def test_truncated_download_raises_and_leaves_no_file(self): with tempfile.TemporaryDirectory() as tmp: - resp = _Response(b'half', content_length=99) + resp = _Response(b'pay', content_length=None) 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', + 'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST, ) self.assertEqual(list(Path(tmp).iterdir()), []) @@ -146,10 +160,41 @@ class DownloadAssetTest(unittest.TestCase): body, _ = self._download(_Response(b'payload', content_length=None)) self.assertEqual(body, b'payload') + def test_same_length_corruption_is_rejected(self): + with self.assertRaisesRegex(OSError, 'SHA-256'): + self._download(_Response(b'payloae')) + + def test_uppercase_checksum_is_accepted(self): + body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper()) + self.assertEqual(body, PAYLOAD) + + def test_stream_failure_removes_partial_file(self): + for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)): + with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp: + response = mock.MagicMock() + response.__enter__.return_value = response + response.read.side_effect = [b'pay', error] + with mock.patch.object(update_check, 'build_github_opener', _opener(response)): + with self.assertRaises(type(error)): + update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST) + self.assertEqual(list(Path(tmp).iterdir()), []) + + def test_hash_covers_every_chunk_and_read_timeout_is_set(self): + body = PAYLOAD * 20000 + opener = _opener(_Response(body)) + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(update_check, 'build_github_opener', opener): + path = update_check.download_asset( + 'https://example.invalid/a', Path(tmp), + digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7, + ) + self.assertEqual(path.read_bytes(), body) + opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7) + class FindAssetTest(unittest.TestCase): def test_returns_asset_with_digest(self): - assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': 'sha256:ab'}] + assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}] 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')) @@ -158,6 +203,135 @@ class FindAssetTest(unittest.TestCase): with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}): self.assertIsNone(update_check.find_asset('a.deb')) + def test_assets_without_a_valid_checksum_require_manual_installation(self): + for digest in ('', 'sha256:ab', 'md5:' + '0' * 32): + asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest} + with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}): + self.assertIsNone(update_check.find_asset('a.exe')) + + +class RunCheckTest(unittest.TestCase): + def setUp(self): + self.patch = mock.patch.object(update_check, '_state', update_check.get_status()) + self.patch.start() + self.addCleanup(self.patch.stop) + + def _check(self, data, current='1.11.1'): + with mock.patch.object(update_check, 'fetch_latest_release', return_value=data): + update_check.run_check(current) + return update_check.get_status() + + def test_versions_are_compared_and_assets_published_together(self): + release = {'tag_name': ' v2.0 ', 'assets': [{ + 'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST, + }]} + for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)): + with self.subTest(current=current): + state = self._check(release, current) + self.assertTrue(state['checked']) + self.assertIsNone(state['error']) + self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead)) + self.assertEqual(state['latest'], '2.0') + self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL) + self.assertEqual(state['assets'][0]['digest'], DIGEST) + + def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self): + for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'}, + {'tag_name': 'v2.0', 'assets': 'invalid'}): + with self.subTest(data=data): + state = self._check(data) + self.assertTrue(state['error']) + self.assertFalse(state['has_update']) + self.assertIsNone(state['latest']) + self.assertEqual(state['assets'], []) + + def test_release_tag_suffixes_keep_the_existing_version_comparison(self): + state = self._check({'tag_name': 'v1.12.0-hotfix'}) + self.assertIsNone(state['error']) + self.assertTrue(state['has_update']) + self.assertEqual(state['latest'], '1.12.0-hotfix') + + def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self): + errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None), + TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json')) + for error in errors: + with self.subTest(error=error): + self._check({'tag_name': 'v2.0', 'assets': [{ + 'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST, + }]}) + with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error): + update_check.run_check('1.11.1') + state = update_check.get_status() + self.assertTrue(state['error']) + self.assertEqual(state['assets'], []) + self.assertFalse(state['has_update']) + self.assertIsNone(self._check({'tag_name': 'v2.1'})['error']) + + def test_readers_do_not_see_partially_updated_status(self): + previous = self._check({'tag_name': 'v1.11.1'}) + started, finish = threading.Event(), threading.Event() + + def fetch(): + started.set() + finish.wait(timeout=5) + return {'tag_name': 'v2.0'} + + with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch): + worker = threading.Thread(target=update_check.run_check, args=('1.11.1',)) + worker.start() + try: + self.assertTrue(started.wait(timeout=5)) + self.assertEqual(update_check.get_status(), previous) + finally: + finish.set() + worker.join(timeout=5) + self.assertEqual(update_check.get_status()['latest'], '2.0') + + def test_asset_and_status_snapshots_cannot_modify_internal_state(self): + self._check({'tag_name': 'v2.0', 'assets': [{ + 'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST, + }]}) + asset = update_check.find_asset('a.exe') + asset['digest'] = 'bad' + snapshot = update_check.get_status() + snapshot['assets'][0]['digest'] = 'bad' + snapshot['assets'].clear() + self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST) + + def test_old_cache_is_ignored_and_no_new_cache_is_written(self): + from utils import tray_common + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / '.update_check_cache.json' + old = '{"last_attempt_at": "invalid", "assets": "invalid"}' + path.write_text(old, encoding='utf-8') + with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)): + self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update']) + self.assertEqual(path.read_text(encoding='utf-8'), old) + self.assertEqual(list(Path(tmp).iterdir()), [path]) + + def test_fetch_is_one_request_without_etag_and_with_timeout(self): + opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode())) + with mock.patch.object(update_check, 'build_github_opener', opener): + self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0') + args, kwargs = opener.return_value.open.call_args + self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API) + self.assertFalse(args[0].has_header('If-none-match')) + self.assertEqual(kwargs['timeout'], 7) + + +class RelaunchEnvTest(unittest.TestCase): + def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self): + bundle = str(Path(tempfile.gettempdir()) / '_MEItest') + env = { + '_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe', + '_MEIPASS': bundle, '_MEIPASS2': bundle, + 'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')), + } + with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True): + result = update_check.relaunch_env() + self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'}) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_windows_update.py b/tests/test_windows_update.py new file mode 100644 index 0000000..098c421 --- /dev/null +++ b/tests/test_windows_update.py @@ -0,0 +1,169 @@ +import hashlib +import io +import sys +import tempfile +import unittest +from contextlib import ExitStack +from pathlib import Path +from unittest import mock + +from utils import update_check, windows_update + + +class AssetNameTest(unittest.TestCase): + def test_keeps_the_running_build_even_when_renamed_or_emulated(self): + builds = ( + ('win-amd64', (3, 11), 'TgWsProxy_windows.exe'), + ('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'), + ('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'), + ('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'), + ('unknown', (3, 11), None), + ) + for platform, version, expected in builds: + with self.subTest(platform=platform, version=version), \ + mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \ + mock.patch.object(sys, 'version_info', version), \ + mock.patch.object(sys, 'executable', 'renamed.exe'): + self.assertEqual(windows_update.asset_name(), expected) + + +class ApplyUpdateTest(unittest.TestCase): + def setUp(self): + stack = ExitStack() + self.addCleanup(stack.close) + directory = stack.enter_context(tempfile.TemporaryDirectory()) + self.exe = Path(directory) / 'renamed app.exe' + self.exe.write_bytes(b'old') + self.backup = self.exe.with_name('renamed app_oldtgws.exe') + self.opener = mock.Mock() + self.opener.open.return_value = io.BytesIO(b'new') + self.asset = {'url': 'https://example.invalid/new.exe', + 'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()} + stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe))) + stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable'])) + stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True)) + stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener)) + stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True)) + stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True)) + self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen')) + self.prepare = mock.Mock() + + def test_verified_update_keeps_legacy_backup_and_launch_arguments(self): + def prepare(): + self.assertEqual(self.exe.read_bytes(), b'new') + self.assertEqual(self.backup.read_bytes(), b'old') + self.launch.assert_not_called() + + windows_update.apply_update(self.asset, prepare) + self.assertEqual(self.exe.read_bytes(), b'new') + self.assertEqual(self.backup.read_bytes(), b'old') + self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup}) + self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable']) + self.assertEqual(self.launch.call_args[1]['creationflags'], 520) + + def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self): + for digest in ('', 'sha256:' + '0' * 64): + with self.subTest(digest=digest): + self.asset['digest'] = digest + with self.assertRaises((OSError, ValueError)): + windows_update.apply_update(self.asset, self.prepare) + self.assertEqual(self.exe.read_bytes(), b'old') + self.assertEqual(list(self.exe.parent.iterdir()), [self.exe]) + self.prepare.assert_not_called() + self.launch.assert_not_called() + + def test_launch_failure_restores_the_old_executable(self): + self.launch.side_effect = OSError('launch failed') + with self.assertRaisesRegex(OSError, 'launch failed'): + windows_update.apply_update(self.asset, self.prepare) + self.prepare.assert_called_once_with() + self.assertEqual(self.exe.read_bytes(), b'old') + self.assertEqual(list(self.exe.parent.iterdir()), [self.exe]) + + def test_rename_failure_leaves_current_executable_and_cleans_download(self): + with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')): + with self.assertRaises(PermissionError): + windows_update.apply_update(self.asset, self.prepare) + self.assertEqual(self.exe.read_bytes(), b'old') + self.assertEqual(list(self.exe.parent.iterdir()), [self.exe]) + self.prepare.assert_not_called() + self.launch.assert_not_called() + + def test_replacement_failure_rolls_back_and_cleans_download(self): + replace = Path.replace + + def fail_download_move(path, target): + if path.suffix == '.tmp': + raise PermissionError('move failed') + return replace(path, target) + + with mock.patch.object(Path, 'replace', fail_download_move): + with self.assertRaisesRegex(PermissionError, 'move failed'): + windows_update.apply_update(self.asset, self.prepare) + self.assertEqual(self.exe.read_bytes(), b'old') + self.assertEqual(list(self.exe.parent.iterdir()), [self.exe]) + self.prepare.assert_not_called() + self.launch.assert_not_called() + + def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self): + dll = mock.Mock() + with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \ + mock.patch.object(windows_update.ctypes, 'windll', dll, create=True): + windows_update.apply_update(self.asset, self.prepare) + self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1') + calls = dll.kernel32.SetDllDirectoryW.call_args_list + self.assertEqual(calls[0], mock.call(None)) + self.assertEqual(calls[1].args[0].value, 'old-bundle') + + +@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration') +class UpdateFlowTest(unittest.TestCase): + def setUp(self): + import windows + + self.windows = windows + + def test_fallback_dialog_actually_installs_the_selected_asset(self): + app = self.windows + asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64} + with mock.patch.object(app, 'ctk', None), \ + mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \ + mock.patch.object(app, '_perform_update') as update: + app.update_ctk_form('update', asset=asset) + update.assert_called_once_with(asset) + + def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self): + app = self.windows + + def fail(asset, before_restart): + before_restart() + raise OSError('launch failed') + + with ExitStack() as stack: + mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in ( + 'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex', + '_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error', + )} + exit_process = stack.enter_context(mock.patch.object(app.os, '_exit')) + mocks['apply_update'].side_effect = fail + mocks['_acquire_win_mutex'].return_value = None + app._perform_update({'url': 'asset', 'digest': 'digest'}) + mocks['stop_proxy'].assert_called_once_with() + mocks['release_lock'].assert_called_once_with() + mocks['_release_win_mutex'].assert_called_once_with() + mocks['acquire_lock'].assert_called_once_with() + mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error']) + mocks['_show_error'].assert_called_once() + exit_process.assert_not_called() + + def test_download_failure_leaves_proxy_and_locks_running(self): + app = self.windows + with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \ + mock.patch.object(app, 'stop_proxy') as stop, \ + mock.patch.object(app, 'release_lock') as unlock, \ + mock.patch.object(app, '_show_error'), \ + mock.patch.object(app.os, '_exit') as exit_process: + app._perform_update({}) + stop.assert_not_called() + unlock.assert_not_called() + exit_process.assert_not_called() diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 7e5c758..8bda0cf 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -140,12 +140,8 @@ "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...", "update.error": "Error: {msg}", - "update.download_fail": "Download failed:\n{error}", - "update.rename_fail": "Failed to rename file:\n{error}", - "update.move_fail": "Failed to move file:\n{error}", "error.dc_config": "DC → IP configuration error.", diff --git a/ui/i18n/ru.json b/ui/i18n/ru.json index 7892b03..e1e5ccc 100644 --- a/ui/i18n/ru.json +++ b/ui/i18n/ru.json @@ -140,12 +140,8 @@ "update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.", "update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?", "update.downloading": "Скачивание...", - "update.replacing": "Замена файла...", "update.restarting": "Перезапуск...", "update.error": "Ошибка: {msg}", - "update.download_fail": "Не удалось скачать:\n{error}", - "update.rename_fail": "Не удалось переименовать файл:\n{error}", - "update.move_fail": "Не удалось переместить файл:\n{error}", "error.dc_config": "Ошибка конфигурации DC → IP.", diff --git a/utils/linux_update.py b/utils/linux_update.py index e2ab163..6970cac 100644 --- a/utils/linux_update.py +++ b/utils/linux_update.py @@ -16,8 +16,11 @@ import shutil import subprocess import sys import tempfile +from http.client import HTTPException from pathlib import Path -from typing import Dict, List, Optional +from typing import List, Optional + +from utils.update_check import download_asset, relaunch_env BINARY_ASSET = "TgWsProxy_linux_amd64" @@ -86,20 +89,25 @@ def install_cmd(kind: str, path: Path) -> List[str]: if kind == "deb": if shutil.which("apt-get"): return elevate + [ - "apt-get", "install", "-y", "--reinstall", - "--allow-downgrades", str(path), + "apt-get", "install", "-y", 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)] + return elevate + ["rpm", "-U", 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)] + """Stage beside the executable so an interrupted copy cannot destroy it.""" + script = '''set -e +staged=$(mktemp "$2.XXXXXX") +trap 'rm -f -- "$staged"' EXIT +install -m 755 -- "$1" "$staged" +mv -fT -- "$staged" "$2" +''' + return _elevate() + ["sh", "-c", script, "sh", str(src), str(exe)] def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]: @@ -125,58 +133,22 @@ def apply_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: + with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory: + tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest) + if kind != "binary": + return _run_install(install_cmd(kind, tmp), log=log) + if in_place: + os.chmod(str(tmp), 0o755) + os.replace(str(tmp), str(exe)) + return None + return _run_install(replace_binary_cmd(tmp, exe), log=log) + except (OSError, ValueError, HTTPException, subprocess.SubprocessError) 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/update_check.py b/utils/update_check.py index 5dcd447..c5eac1a 100644 --- a/utils/update_check.py +++ b/utils/update_check.py @@ -1,387 +1,161 @@ -""" -Проверка новой версии через GitHub Releases API - -Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге -данных приложения). Поддерживается If-None-Match (ETag) для ответа 304. -""" +"""One startup check against GitHub Releases and verified asset downloads.""" from __future__ import annotations import hashlib import json +import logging import os +import re import sys import tempfile -import time +from contextlib import suppress +from http.client import HTTPException from itertools import zip_longest from pathlib import Path -from typing import Any, Dict, Optional, Tuple -from urllib.error import HTTPError, URLError +from typing import Any, Dict, Optional from urllib.request import Request + from proxy.utils import build_github_opener REPO = "Flowseal/tg-ws-proxy" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" -RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest" -# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag). -_MIN_FETCH_INTERVAL_SEC = 3600.0 - +_SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE) _state: Dict[str, Any] = { "checked": False, "has_update": False, "ahead_of_release": False, "latest": None, - "html_url": None, + "html_url": RELEASES_PAGE_URL, "error": None, "assets": [], } -def _cache_file() -> Optional[Path]: - try: - from utils.tray_common import APP_DIR - root = APP_DIR - root.mkdir(parents=True, exist_ok=True) - return root / ".update_check_cache.json" - except OSError: - return None - - -def _load_cache(path: Optional[Path]) -> Dict[str, Any]: - if not path or not path.is_file(): - return {} - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - - -def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None: - if not path: - return - try: - path.write_text(json.dumps(data), encoding="utf-8") - except OSError: - pass - - def _parse_version_tuple(s: str) -> tuple: - s = (s or "").strip().lstrip("vV") - if not s: - return (0,) parts = [] - for seg in s.split("."): - digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg) - if digits: - try: - parts.append(int(digits)) - except ValueError: - parts.append(0) - else: - parts.append(0) - return tuple(parts) if parts else (0,) + for part in (s or "").strip().lstrip("vV").split("."): + match = re.match(r"\d+", part) + parts.append(int(match[0]) if match else 0) + return tuple(parts) def _version_gt(a: str, b: str) -> bool: - """True, если версия a новее b (простое сравнение по сегментам).""" - ta = _parse_version_tuple(a) - tb = _parse_version_tuple(b) - for x, y in zip_longest(ta, tb, fillvalue=0): - if x > y: - return True - if x < y: - return False + for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0): + if x != y: + return x > y return False -def _apply_release_tag( - tag: str, html_url: str, current_version: str, -) -> None: - global _state - if not tag: - _state["has_update"] = False - _state["ahead_of_release"] = False - _state["latest"] = None - _state["html_url"] = html_url.strip() or RELEASES_PAGE_URL - return - latest_clean = tag.lstrip("vV") - cur = (current_version or "").strip().lstrip("vV") - _state["latest"] = latest_clean - _state["html_url"] = html_url.strip() or RELEASES_PAGE_URL - _state["has_update"] = _version_gt(latest_clean, cur) - _state["ahead_of_release"] = bool(latest_clean) and _version_gt( - cur, latest_clean - ) - - -def fetch_latest_release( - timeout: float = 12.0, - etag: Optional[str] = None, -) -> Tuple[Optional[dict], Optional[str], int]: - """ - GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код). - """ - headers = { +def fetch_latest_release(timeout: float = 12.0) -> dict: + req = Request(RELEASES_LATEST_API, headers={ "Accept": "application/vnd.github+json", "User-Agent": "tg-ws-proxy-update-check", - } - if etag: - headers["If-None-Match"] = etag - req = Request( - RELEASES_LATEST_API, - headers=headers, - method="GET", - ) - try: - with build_github_opener().open(req, timeout=timeout) as resp: - code = getattr(resp, "status", None) or resp.getcode() - new_etag = resp.headers.get("ETag") - raw = resp.read().decode("utf-8", errors="replace") - return json.loads(raw), new_etag, int(code) - except HTTPError as e: - if e.code == 304: - hdrs = e.headers - new_etag = hdrs.get("ETag") if hdrs else None - return None, new_etag or etag, 304 - raise + }) + with build_github_opener().open(req, timeout=timeout) as resp: + return json.load(resp) -def run_check(current_version: str) -> None: - """Запрашивает последний релиз и обновляет внутреннее состояние.""" - global _state - _state["checked"] = True - _state["error"] = None - - cache_path = _cache_file() - cache = _load_cache(cache_path) - now = time.time() - last_attempt = float(cache.get("last_attempt_at") or 0) - - if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC: - 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"] = ( - err if err else "Проверка обновлений отложена (интервал между запросами)." - ) - _state["has_update"] = False - _state["ahead_of_release"] = False - _state["latest"] = None - _state["html_url"] = RELEASES_PAGE_URL - return - - etag = (cache.get("etag") or "").strip() or None - try: - data, new_etag, code = fetch_latest_release(etag=etag) - cache["last_attempt_at"] = now - if code == 304: - 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) - return - - assert data is not None - tag = (data.get("tag_name") or "").strip() - html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL - if not tag: - _state["has_update"] = False - _state["ahead_of_release"] = False - _state["latest"] = None - _state["html_url"] = html_url - else: - _apply_release_tag(tag, html_url, current_version) - if new_etag: - cache["etag"] = new_etag - cache["tag_name"] = tag - cache["html_url"] = html_url - assets = [ - {"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")} - for a in (data.get("assets") or []) - if a.get("name") and a.get("browser_download_url") - ] - _state["assets"] = assets - cache["assets"] = assets - cache.pop("last_error", None) - _save_cache(cache_path, cache) - except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e: - cache["last_attempt_at"] = now - msg = str(e) - if isinstance(e, HTTPError) and e.code == 403: - msg = ( - "GitHub API вернул 403 (лимит или доступ). Повторите позже." - ) - cache["last_error"] = msg - _save_cache(cache_path, cache) - _state["error"] = msg - _state["has_update"] = False - _state["ahead_of_release"] = False - _state["latest"] = None - _state["html_url"] = RELEASES_PAGE_URL - - -def fetch_release_by_tag( - tag: str, timeout: float = 12.0, -) -> Tuple[Optional[dict], int]: - if not tag: - return None, 0 - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "tg-ws-proxy-update-check", - } - req = Request( - RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())), - headers=headers, - method="GET", - ) - try: - with build_github_opener().open(req, timeout=timeout) as resp: - code = getattr(resp, "status", None) or resp.getcode() - raw = resp.read().decode("utf-8", errors="replace") - return json.loads(raw), int(code) - except HTTPError as e: - if e.code in [304, 404]: - return None, e.code - raise - - -def _extract_assets(data: Optional[dict]) -> list: - if not data: - return [] +def _extract_assets(data: dict) -> list: + assets = (data or {}).get("assets") or [] + if not isinstance(assets, list): + raise ValueError("Invalid release assets from GitHub") return [ - {"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")} - for a in (data.get("assets") or []) - if a.get("name") and a.get("browser_download_url") + {"name": a["name"], "url": a["browser_download_url"], + "digest": a.get("digest") if isinstance(a.get("digest"), str) else ""} + for a in assets + if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"] + and isinstance(a.get("browser_download_url"), str) and a["browser_download_url"] ] -def _expected_length(resp: Any) -> Optional[int]: +def run_check(current_version: str) -> None: + """Publish the complete result at once; no persistent cache or error cooldown.""" + global _state + state = { + "checked": True, "has_update": False, "ahead_of_release": False, + "latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [], + } try: - return int(resp.headers.get("Content-Length")) - except (AttributeError, TypeError, ValueError): - return None + data = fetch_latest_release() + if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str): + raise ValueError("Invalid release from GitHub") + latest = data["tag_name"].strip().lstrip("vV") + if not latest or not latest[0].isdigit(): + raise ValueError("Invalid release version from GitHub") + state.update( + latest=latest, + has_update=_version_gt(latest, current_version), + ahead_of_release=_version_gt(current_version, latest), + html_url=data.get("html_url") or RELEASES_PAGE_URL, + assets=_extract_assets(data), + ) + except (OSError, ValueError, HTTPException) as exc: + state["error"] = str(exc) or type(exc).__name__ + logging.getLogger(__name__).warning("Update check failed: %s", exc) + _state = state def download_asset( url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "", timeout: float = 30.0, ) -> Path: - """Download a release asset into a temporary file inside dest_dir. + """Return a temporary file only after its mandatory SHA-256 has matched.""" + expected = _SHA256.fullmatch(digest if isinstance(digest, str) else "") + if not expected: + raise ValueError("The release asset has no valid SHA-256 checksum") - 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. - timeout limits blocking network operations, including response reads. - - 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, timeout=timeout) 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") + with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp: + while True: + chunk = resp.read(65536) + if not chunk: + break + out.write(chunk) + sha.update(chunk) + if sha.hexdigest() != expected[1].lower(): + raise OSError("SHA-256 checksum does not match the release asset") return path except BaseException: - try: - path.unlink(missing_ok=True) - except OSError: - pass + with suppress(OSError): + path.unlink() 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 + """Find an asset suitable for automatic installation.""" + for asset in _state["assets"]: + if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]): + return dict(asset) return None def get_status() -> Dict[str, Any]: - """Снимок состояния после run_check (для подписей в настройках).""" - return dict(_state) + state = _state + return {**state, "assets": [dict(a) for a in state["assets"]]} -def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]: - new_assets = _state.get("assets") or [] - if not new_assets: - return None - - target_name = None - - # SHA256 match - try: - data, code = fetch_release_by_tag(f"v{current_version}") - if code == 200 and data: - cur_assets = _extract_assets(data) - if cur_assets: - h = hashlib.sha256() - with open(exe_path, "rb") as f: - while True: - chunk = f.read(65536) - if not chunk: - break - h.update(chunk) - exe_sha = h.hexdigest().lower() - for a in cur_assets: - d = (a.get("digest") or "").lower() - if d.startswith("sha256:") and d[7:] == exe_sha: - target_name = a["name"] - break - except Exception: - pass - - # Fallback - if not target_name or target_name not in [a.get("name") for a in new_assets]: - import platform - import struct - - is_64 = struct.calcsize("P") * 8 == 64 - machine = platform.machine().lower() - is_arm64 = machine in ("arm64", "aarch64") - - try: - is_modern = sys.getwindowsversion().major >= 10 - except Exception: - is_modern = True - - if is_arm64: - target_name = "TgWsProxy_windows_arm64.exe" - elif is_modern: - target_name = "TgWsProxy_windows.exe" - elif is_64: - target_name = "TgWsProxy_windows_7_64bit.exe" - else: - target_name = "TgWsProxy_windows_7_32bit.exe" - - for a in new_assets: - if a.get("name") == target_name: - return a["url"], a["name"] - - return None +def relaunch_env() -> Dict[str, str]: + """Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6.""" + env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))} + mei = getattr(sys, "_MEIPASS", None) + if mei: + env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + 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 + elif mei and env.get(var): + bundle = os.path.normcase(os.path.abspath(mei)) + kept = [p for p in env[var].split(os.pathsep) + if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)] + if kept: + env[var] = os.pathsep.join(kept) + else: + env.pop(var, None) + return env diff --git a/utils/windows_update.py b/utils/windows_update.py new file mode 100644 index 0000000..b6cfcb1 --- /dev/null +++ b/utils/windows_update.py @@ -0,0 +1,54 @@ +"""Windows build selection and executable replacement with rollback.""" +from __future__ import annotations + +import ctypes +import subprocess +import sys +import sysconfig +from contextlib import suppress +from pathlib import Path +from typing import Callable, Optional + +from utils.update_check import download_asset, relaunch_env + + +def asset_name() -> Optional[str]: + # Use the running build, including x86/x64 emulation and renamed executables. + target = sysconfig.get_platform() + if target == "win-arm64": + return "TgWsProxy_windows_arm64.exe" + if target == "win32": + return "TgWsProxy_windows_7_32bit.exe" + if target == "win-amd64": + return "TgWsProxy_windows_7_64bit.exe" if sys.version_info < (3, 9) else "TgWsProxy_windows.exe" + return None + + +def apply_update(asset: dict, before_restart: Callable[[], None]) -> None: + """Verify, replace and launch; keep the old executable if any step fails.""" + exe = Path(sys.executable) + backup = exe.with_name(exe.stem + "_oldtgws.exe") + downloaded = download_asset(asset["url"], exe.parent, digest=asset["digest"]) + try: + backup.unlink(missing_ok=True) + exe.rename(backup) + try: + downloaded.replace(exe) + before_restart() + bundle = getattr(sys, "_MEIPASS", None) + if bundle: + ctypes.windll.kernel32.SetDllDirectoryW(None) + try: + subprocess.Popen( + [str(exe), *sys.argv[1:]], env=relaunch_env(), + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + ) + finally: + if bundle: + ctypes.windll.kernel32.SetDllDirectoryW(ctypes.c_wchar_p(bundle)) + except BaseException: + backup.replace(exe) + raise + finally: + with suppress(OSError): + downloaded.unlink(missing_ok=True) diff --git a/windows.py b/windows.py index 95323dc..0725fb5 100644 --- a/windows.py +++ b/windows.py @@ -2,7 +2,6 @@ from __future__ import annotations import ctypes import os -import subprocess import sys import threading import time @@ -40,14 +39,13 @@ from utils.win32_theme import ( ) from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, - acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, + 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 utils.update_check import ( - download_asset, get_status, get_update_asset, run_check, RELEASES_PAGE_URL, -) +from utils.update_check import find_asset, get_status, RELEASES_PAGE_URL +from utils.windows_update import apply_update, asset_name from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, @@ -126,14 +124,16 @@ def _ask_yes_no(text: str, title: Optional[str] = None) -> bool: def update_ctk_form( - text: str, title: Optional[str] = None, download_url: Optional[str] = None, + text: str, title: Optional[str] = None, asset: Optional[dict] = None, release_url: Optional[str] = None, ) -> str: title = title or t("app.name") if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): + if not asset: + return "open" if _ask_yes_no(t("update.ask_open", version=get_status()["latest"]), title) else "close" result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q) if result == _IDYES: - return "update" + _perform_update(asset) if result == _IDNO: return "open" return "close" @@ -145,8 +145,8 @@ def update_ctk_form( root = create_ctk_toplevel( ctk, title=title, - width=310 if IS_FROZEN else 210, - height=130 if IS_FROZEN else 100, + width=310 if asset else 210, + height=130 if asset else 100, theme=theme, topmost=False, after_create=lambda r: r.iconbitmap(ICON_PATH), @@ -183,21 +183,16 @@ def update_ctk_form( done.set() def _on_update() -> None: - if not download_url: - if release_url: - webbrowser.open(release_url) - _close_with("open") - return for b in btns: b.configure(state="disabled") root.protocol("WM_DELETE_WINDOW", lambda: None) def _run(): - _perform_update(download_url, set_status=_set_status) + _perform_update(asset, set_status=_set_status) root.after(0, lambda: [b.configure(state="normal") for b in btns]) root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))) threading.Thread(target=_run, daemon=True).start() - if IS_FROZEN: + if asset: btn_upd = ctk.CTkButton( row, text=t("button.update"), width=88, height=34, font=(theme.ui_font_family, 13), command=_on_update, @@ -226,81 +221,37 @@ def update_ctk_form( return result["value"] -def _perform_update(download_url: str, set_status=None) -> None: +def _perform_update(asset: dict, set_status=None) -> None: + restarting = False + def _step(msg: str) -> None: log.info("Update: %s", msg) if set_status: set_status(msg) - time.sleep(0.8) - def _err(msg: str) -> None: - log.error("Update error: %s", msg) - if set_status: - set_status(f"{t('update.error', msg=msg)}") - else: - _show_error(msg) + def _before_restart() -> None: + nonlocal restarting + _step(t("update.restarting")) + restarting = True + stop_proxy() + release_lock() + _release_win_mutex() _step(t("update.downloading")) - cur_exe = Path(sys.executable) - old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe") try: - log.info("Downloading update from %s", download_url) - tmp_path = download_asset(download_url, cur_exe.parent) + apply_update(asset, _before_restart) except Exception as exc: - _err(t("update.download_fail", error=exc)) + log.exception("Update failed") + if restarting: + mutex = _acquire_win_mutex() + if mutex is True or mutex is None and acquire_lock(): + start_proxy(_config, _show_error) + msg = t("update.error", msg=exc) + if set_status: + set_status(msg) + else: + _show_error(msg) return - - _step(t("update.replacing")) - try: - if old_exe.exists(): - old_exe.unlink() - cur_exe.rename(old_exe) - except Exception as exc: - _err(t("update.rename_fail", error=exc)) - try: - tmp_path.unlink(missing_ok=True) - except OSError: - pass - return - - try: - tmp_path.rename(cur_exe) - except Exception as exc: - _err(t("update.move_fail", error=exc)) - try: - old_exe.rename(cur_exe) - except OSError: - pass - try: - tmp_path.unlink(missing_ok=True) - except OSError: - pass - return - - _step(t("update.restarting")) - _release_win_mutex() - stop_proxy() - - # Don't reuse existing _MEI* dir - env = os.environ.copy() - for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]: - del env[_k] - if hasattr(sys, "_MEIPASS"): - _mei = os.path.normcase(sys._MEIPASS.rstrip("\\/")) - env["PATH"] = os.pathsep.join( - p for p in env.get("PATH", "").split(os.pathsep) - if os.path.normcase(p.rstrip("\\/")) != _mei - ) - - try: - subprocess.Popen( - [str(cur_exe)], - env=env, - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, - ) - except Exception as exc: - log.error("Failed to launch updated exe: %s", exc) - time.sleep(0.5) os._exit(0) @@ -321,13 +272,15 @@ def _trigger_update_flow(icon=None, item=None) -> None: st = get_status() if not st.get("has_update"): return + if _tray_icon is not None: + _tray_icon.menu = _build_menu() 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 + asset = find_asset(asset_name()) if IS_FROZEN else None choice = update_ctk_form( t("update.available", version=ver), - download_url=asset[0] if asset else None, + asset=asset, release_url=url, ) if choice == "open": @@ -340,27 +293,6 @@ def _trigger_update_flow(icon=None, item=None) -> None: threading.Thread(target=_show, daemon=True, name="manual-update").start() -def _maybe_do_update(cfg: dict, is_exiting) -> None: - if not cfg.get("check_updates", True): - return - - def _work(): - time.sleep(1.5) - if is_exiting(): - return - try: - run_check(__version__) - if _tray_icon is not None: - _tray_icon.menu = _build_menu() - if is_exiting(): - return - _trigger_update_flow() - except Exception as exc: - log.warning("Update check failed: %s", repr(exc)) - - threading.Thread(target=_work, daemon=True, name="update-check").start() - - # autostart (registry) _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" @@ -616,7 +548,7 @@ def run_tray() -> None: return start_proxy(_config, _show_error) - _maybe_do_update(_config, lambda: _exiting) + check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow()) _show_first_run() check_ipv6_warning(_show_info) @@ -635,14 +567,13 @@ def main() -> None: if IS_FROZEN: def _cleanup_old_exes(): - exe_dir = Path(sys.executable).parent time.sleep(3) - for _f in exe_dir.glob("*_oldtgws.exe"): - try: - _f.unlink() - log.info("Deleted leftover: %s", _f) - except OSError: - pass + exe = Path(sys.executable) + backup = exe.with_name(exe.stem + "_oldtgws.exe") + try: + backup.unlink(missing_ok=True) + except OSError: + pass threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start() try: