Updates refactoring

This commit is contained in:
Flowseal committed 2026-10-11 15:55:00 +03:00
1 parent c317406b52
commit 03be9baad1
10 files changed
+752 -563

No files matched your search

+48 -50
View File
@@ -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)
+138 -13
View File
@@ -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()
+181 -7
View File
@@ -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()
+169
View File
@@ -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()
-4
View File
@@ -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.",
-4
View File
@@ -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.",
+24 -52
View File
@@ -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
+95 -321
View File
@@ -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
+54
View File
@@ -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)
+43 -112
View File
@@ -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: