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
+731 -542

No files matched your search

+43 -45
View File
@@ -1,21 +1,19 @@
from __future__ import annotations from __future__ import annotations
import errno
import os import os
import socket
import subprocess import subprocess
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
from typing import Callable, Optional from typing import Optional
import customtkinter as ctk import customtkinter as ctk
import pyperclip import pyperclip
import pystray import pystray
from PIL import Image, ImageTk from PIL import Image, ImageTk
from proxy import get_link_host from proxy import __version__, get_link_host
from utils.tray_common import ( from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, 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.i18n import t
from ui.settings_dialog import SettingsDialog from ui.settings_dialog import SettingsDialog
from utils.update_check import get_status, relaunch_env
_tray_icon: Optional[object] = None _tray_icon: Optional[object] = None
_config: dict = {} _config: dict = {}
_exiting = False _exiting = False
_update_flow_lock = threading.Lock()
# dialogs (tkinter messagebox) # dialogs (tkinter messagebox)
@@ -164,6 +164,7 @@ def _edit_config_dialog() -> None:
widgets = install_tray_config_form( widgets = install_tray_config_form(
ctk, scroll, theme, cfg, DEFAULT_CONFIG, ctk, scroll, theme, cfg, DEFAULT_CONFIG,
show_autostart=False, show_autostart=False,
on_update_click=_trigger_update_flow,
) )
def _finish() -> None: def _finish() -> None:
@@ -221,66 +222,57 @@ def _show_first_run() -> None:
# update # 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: 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
from utils.linux_update import exe_path, relaunch_env
release_lock()
stop_proxy() stop_proxy()
_wait_port_free( release_lock()
_config.get("host", DEFAULT_CONFIG["host"]), exe = str(exe_path())
int(_config.get("port", DEFAULT_CONFIG["port"])),
)
try: try:
subprocess.Popen( subprocess.Popen(
[str(exe_path())], env=relaunch_env(), [exe, *sys.argv[1:]], env=relaunch_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL, start_new_session=True, stdin=subprocess.DEVNULL, start_new_session=True,
) )
except OSError as exc: except OSError as exc:
log.error("Failed to launch updated binary: %s", 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) os._exit(0)
def _maybe_do_update(cfg: dict, is_exiting: Callable[[], bool]) -> None: def _trigger_update_flow(icon=None, item=None) -> None:
def _on_update(version: str, page_url: str) -> None: if not _update_flow_lock.acquire(blocking=False):
from utils.linux_update import asset_name, detect_install_kind return
from utils.update_check import find_asset
def _work() -> None:
from utils.linux_update import asset_name, detect_install_kind
from utils.update_check import find_asset, RELEASES_PAGE_URL
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 kind = detect_install_kind() if IS_FROZEN else None
asset = find_asset(asset_name(kind)) if kind 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 not asset:
log.info("In-place update unavailable (kind=%s)", kind) if _ask_yes_no(t("update.ask_open", version=st["latest"]), t("app.update_title")):
if _ask_yes_no(t("update.ask_open", version=version), t("app.update_title")):
webbrowser.open(page_url) webbrowser.open(page_url)
return return
if not _ask_yes_no(t("update.ask_install", version=version), t("app.update_title")): if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")):
return
_install_update(kind, asset, page_url) _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: 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"]) host = _config.get("host", DEFAULT_CONFIG["host"])
port = _config.get("port", DEFAULT_CONFIG["port"]) port = _config.get("port", DEFAULT_CONFIG["port"])
link_host = get_link_host(host) 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.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
pystray.MenuItem(t("tray.copy_link"), _on_copy_link), pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
@@ -315,7 +307,13 @@ def _build_menu():
pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.MenuItem(t("tray.logs"), _on_open_logs),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.exit"), _on_exit), 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 # entry point
@@ -338,7 +336,7 @@ def run_tray() -> None:
return return
start_proxy(_config, _show_error) 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() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
+138 -13
View File
@@ -1,12 +1,18 @@
import hashlib
import io
import os import os
import shutil
import stat import stat
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from contextlib import ExitStack
from http.client import IncompleteRead
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from utils import linux_update from utils import linux_update, update_check
class _Proc: class _Proc:
@@ -97,7 +103,7 @@ class InstallCmdTest(unittest.TestCase):
def test_rpm_falls_back_to_rpm_binary(self): def test_rpm_falls_back_to_rpm_binary(self):
self.assertEqual( 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): def test_unknown_kind_is_rejected(self):
@@ -127,7 +133,7 @@ class ApplyUpdateTest(unittest.TestCase):
exe.write_bytes(b'old') exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ 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.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(exe.read_bytes(), b'new') self.assertEqual(exe.read_bytes(), b'new')
@@ -139,7 +145,7 @@ class ApplyUpdateTest(unittest.TestCase):
exe = Path(tmp) / 'TgWsProxy' exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old') exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ 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.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755) 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), \ 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.os, 'access', return_value=False), \
mock.patch.object(linux_update, '_is_root', 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): mock.patch.object(linux_update.subprocess, 'run', run):
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a')) self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
cmd = run.call_args[0][0] 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)) self.assertEqual(cmd[-1], str(exe))
def test_deb_install_failure_returns_stderr(self): def test_deb_install_failure_returns_stderr(self):
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error')) 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')), \ 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.subprocess, 'run', run), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \ mock.patch.object(linux_update.shutil, 'which', return_value=None), \
mock.patch.object(linux_update, '_is_root', return_value=True): mock.patch.object(linux_update, '_is_root', return_value=True):
@@ -184,7 +190,7 @@ class ApplyUpdateTest(unittest.TestCase):
return path return path
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \ 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', mock.patch.object(linux_update.subprocess, 'run',
return_value=_Proc(0)), \ return_value=_Proc(0)), \
mock.patch.object(linux_update.shutil, 'which', return_value=None), \ mock.patch.object(linux_update.shutil, 'which', return_value=None), \
@@ -206,16 +212,99 @@ class ApplyUpdateTest(unittest.TestCase):
exe = Path(tmp) / 'TgWsProxy' exe = Path(tmp) / 'TgWsProxy'
exe.write_bytes(b'old') exe.write_bytes(b'old')
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \ 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') linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
self.assertEqual(seen['digest'], 'sha256:ab') self.assertEqual(seen['digest'], 'sha256:ab')
def test_download_failure_is_reported(self): def test_download_failure_is_reported(self):
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \ for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
mock.patch('utils.update_check.download_asset', with self.subTest(error=error), \
side_effect=OSError('boom')): mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), 'boom') 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): class ExternalCommandsEnvTest(unittest.TestCase):
@@ -276,5 +365,41 @@ class RelaunchEnvTest(unittest.TestCase):
self.assertEqual(out, env) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+181 -7
View File
@@ -1,13 +1,22 @@
import hashlib import hashlib
import io import io
import json
import os
import sys
import tempfile import tempfile
import threading
import unittest import unittest
from http.client import IncompleteRead
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from urllib.error import HTTPError, URLError
from utils import update_check from utils import update_check
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt 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): class ParseVersionTupleTest(unittest.TestCase):
def test_plain_and_prefixed_versions(self): def test_plain_and_prefixed_versions(self):
@@ -96,7 +105,7 @@ def _opener(response):
class DownloadAssetTest(unittest.TestCase): class DownloadAssetTest(unittest.TestCase):
def _download(self, response, digest=''): def _download(self, response, digest=DIGEST):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(update_check, 'build_github_opener', _opener(response)): with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
path = update_check.download_asset( path = update_check.download_asset(
@@ -117,17 +126,22 @@ class DownloadAssetTest(unittest.TestCase):
body, _ = self._download(_Response(b'payload'), digest=digest) body, _ = self._download(_Response(b'payload'), digest=digest)
self.assertEqual(body, b'payload') self.assertEqual(body, b'payload')
def test_unknown_digest_format_is_ignored(self): def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
body, _ = self._download(_Response(b'payload'), digest='md5:whatever') for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
self.assertEqual(body, b'payload') 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): def test_truncated_download_raises_and_leaves_no_file(self):
with tempfile.TemporaryDirectory() as tmp: 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 mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
with self.assertRaises(OSError): with self.assertRaises(OSError):
update_check.download_asset( 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()), []) self.assertEqual(list(Path(tmp).iterdir()), [])
@@ -146,10 +160,41 @@ class DownloadAssetTest(unittest.TestCase):
body, _ = self._download(_Response(b'payload', content_length=None)) body, _ = self._download(_Response(b'payload', content_length=None))
self.assertEqual(body, b'payload') 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): class FindAssetTest(unittest.TestCase):
def test_returns_asset_with_digest(self): 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}): with mock.patch.dict(update_check._state, {'assets': assets}):
self.assertEqual(update_check.find_asset('a.deb'), assets[0]) self.assertEqual(update_check.find_asset('a.deb'), assets[0])
self.assertIsNone(update_check.find_asset('b.deb')) 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': ''}]}): with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
self.assertIsNone(update_check.find_asset('a.deb')) 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__': if __name__ == '__main__':
unittest.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.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.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?",
"update.downloading": "Downloading...", "update.downloading": "Downloading...",
"update.replacing": "Replacing file...",
"update.restarting": "Restarting...", "update.restarting": "Restarting...",
"update.error": "Error: {msg}", "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.", "error.dc_config": "DC → IP configuration error.",
-4
View File
@@ -140,12 +140,8 @@
"update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.", "update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.",
"update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?", "update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?",
"update.downloading": "Скачивание...", "update.downloading": "Скачивание...",
"update.replacing": "Замена файла...",
"update.restarting": "Перезапуск...", "update.restarting": "Перезапуск...",
"update.error": "Ошибка: {msg}", "update.error": "Ошибка: {msg}",
"update.download_fail": "Не удалось скачать:\n{error}",
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
"update.move_fail": "Не удалось переместить файл:\n{error}",
"error.dc_config": "Ошибка конфигурации DC → IP.", "error.dc_config": "Ошибка конфигурации DC → IP.",
+18 -46
View File
@@ -16,8 +16,11 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from http.client import HTTPException
from pathlib import Path 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" BINARY_ASSET = "TgWsProxy_linux_amd64"
@@ -86,20 +89,25 @@ def install_cmd(kind: str, path: Path) -> List[str]:
if kind == "deb": if kind == "deb":
if shutil.which("apt-get"): if shutil.which("apt-get"):
return elevate + [ return elevate + [
"apt-get", "install", "-y", "--reinstall", "apt-get", "install", "-y", str(path),
"--allow-downgrades", str(path),
] ]
return elevate + ["dpkg", "-i", str(path)] return elevate + ["dpkg", "-i", str(path)]
if kind == "rpm": if kind == "rpm":
if shutil.which("dnf"): if shutil.which("dnf"):
return elevate + ["dnf", "install", "-y", str(path)] 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)) raise ValueError("no install command for kind: {0}".format(kind))
def replace_binary_cmd(src: Path, exe: Path) -> List[str]: def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
"""Replace the binary as root; install(1) avoids ETXTBSY on the running file.""" """Stage beside the executable so an interrupted copy cannot destroy it."""
return _elevate() + ["install", "-m", "755", str(src), str(exe)] 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]: def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]:
@@ -125,58 +133,22 @@ def apply_update(
Returns: Returns:
Error text, or None on success. Error text, or None on success.
""" """
from utils.update_check import download_asset
exe = exe_path() exe = exe_path()
suffix = ASSET_SUFFIX[kind] or ".new" suffix = ASSET_SUFFIX[kind] or ".new"
# with a writable directory download next to the binary and os.replace it # 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) 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: if log:
log.info("Linux update (%s): downloading %s", kind, url) log.info("Linux update (%s): downloading %s", kind, url)
try: try:
tmp = download_asset(url, dest_dir, suffix=suffix, digest=digest) with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
except Exception as exc: tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
return str(exc)
try:
if kind != "binary": if kind != "binary":
return _run_install(install_cmd(kind, tmp), log=log) return _run_install(install_cmd(kind, tmp), log=log)
os.chmod(str(tmp), 0o755)
if in_place: if in_place:
os.chmod(str(tmp), 0o755)
os.replace(str(tmp), str(exe)) os.replace(str(tmp), str(exe))
return None return None
return _run_install(replace_binary_cmd(tmp, exe), log=log) return _run_install(replace_binary_cmd(tmp, exe), log=log)
except (OSError, ValueError, subprocess.SubprocessError) as exc: except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
return str(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
+88 -314
View File
@@ -1,387 +1,161 @@
""" """One startup check against GitHub Releases and verified asset downloads."""
Проверка новой версии через GitHub Releases API
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
"""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import os import os
import re
import sys import sys
import tempfile import tempfile
import time from contextlib import suppress
from http.client import HTTPException
from itertools import zip_longest from itertools import zip_longest
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request from urllib.request import Request
from proxy.utils import build_github_opener from proxy.utils import build_github_opener
REPO = "Flowseal/tg-ws-proxy" REPO = "Flowseal/tg-ws-proxy"
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" 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" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag). _SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE)
_MIN_FETCH_INTERVAL_SEC = 3600.0
_state: Dict[str, Any] = { _state: Dict[str, Any] = {
"checked": False, "checked": False,
"has_update": False, "has_update": False,
"ahead_of_release": False, "ahead_of_release": False,
"latest": None, "latest": None,
"html_url": None, "html_url": RELEASES_PAGE_URL,
"error": None, "error": None,
"assets": [], "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: def _parse_version_tuple(s: str) -> tuple:
s = (s or "").strip().lstrip("vV")
if not s:
return (0,)
parts = [] parts = []
for seg in s.split("."): for part in (s or "").strip().lstrip("vV").split("."):
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg) match = re.match(r"\d+", part)
if digits: parts.append(int(match[0]) if match else 0)
try: return tuple(parts)
parts.append(int(digits))
except ValueError:
parts.append(0)
else:
parts.append(0)
return tuple(parts) if parts else (0,)
def _version_gt(a: str, b: str) -> bool: def _version_gt(a: str, b: str) -> bool:
"""True, если версия a новее b (простое сравнение по сегментам).""" for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0):
ta = _parse_version_tuple(a) if x != y:
tb = _parse_version_tuple(b) return x > y
for x, y in zip_longest(ta, tb, fillvalue=0):
if x > y:
return True
if x < y:
return False
return False return False
def _apply_release_tag( def fetch_latest_release(timeout: float = 12.0) -> dict:
tag: str, html_url: str, current_version: str, req = Request(RELEASES_LATEST_API, headers={
) -> 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 = {
"Accept": "application/vnd.github+json", "Accept": "application/vnd.github+json",
"User-Agent": "tg-ws-proxy-update-check", "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: with build_github_opener().open(req, timeout=timeout) as resp:
code = getattr(resp, "status", None) or resp.getcode() return json.load(resp)
new_etag = resp.headers.get("ETag")
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw), new_etag, int(code) def _extract_assets(data: dict) -> list:
except HTTPError as e: assets = (data or {}).get("assets") or []
if e.code == 304: if not isinstance(assets, list):
hdrs = e.headers raise ValueError("Invalid release assets from GitHub")
new_etag = hdrs.get("ETag") if hdrs else None return [
return None, new_etag or etag, 304 {"name": a["name"], "url": a["browser_download_url"],
raise "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 run_check(current_version: str) -> None: def run_check(current_version: str) -> None:
"""Запрашивает последний релиз и обновляет внутреннее состояние.""" """Publish the complete result at once; no persistent cache or error cooldown."""
global _state global _state
_state["checked"] = True state = {
_state["error"] = None "checked": True, "has_update": False, "ahead_of_release": False,
"latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [],
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( try:
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())), data = fetch_latest_release()
headers=headers, if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str):
method="GET", 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),
) )
try: except (OSError, ValueError, HTTPException) as exc:
with build_github_opener().open(req, timeout=timeout) as resp: state["error"] = str(exc) or type(exc).__name__
code = getattr(resp, "status", None) or resp.getcode() logging.getLogger(__name__).warning("Update check failed: %s", exc)
raw = resp.read().decode("utf-8", errors="replace") _state = state
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 []
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")
]
def _expected_length(resp: Any) -> Optional[int]:
try:
return int(resp.headers.get("Content-Length"))
except (AttributeError, TypeError, ValueError):
return None
def download_asset( def download_asset(
url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "", url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "",
timeout: float = 30.0, timeout: float = 30.0,
) -> Path: ) -> 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) fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix)
os.close(fd)
path = Path(name) path = Path(name)
try: try:
sha = hashlib.sha256() sha = hashlib.sha256()
written = 0 with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp:
with build_github_opener().open(url, timeout=timeout) as resp:
expected = _expected_length(resp)
with open(str(path), "wb") as out:
while True: while True:
chunk = resp.read(65536) chunk = resp.read(65536)
if not chunk: if not chunk:
break break
out.write(chunk) out.write(chunk)
sha.update(chunk) sha.update(chunk)
written += len(chunk) if sha.hexdigest() != expected[1].lower():
if expected is not None and written != expected: raise OSError("SHA-256 checksum does not match the release asset")
raise OSError(
"incomplete download: {0} of {1} bytes".format(written, expected)
)
want = (digest or "").lower()
if want.startswith("sha256:") and sha.hexdigest() != want[7:]:
raise OSError("checksum does not match the release asset")
return path return path
except BaseException: except BaseException:
try: with suppress(OSError):
path.unlink(missing_ok=True) path.unlink()
except OSError:
pass
raise raise
def find_asset(name: str) -> Optional[Dict[str, str]]: def find_asset(name: str) -> Optional[Dict[str, str]]:
"""Asset of the latest release by name (after run_check).""" """Find an asset suitable for automatic installation."""
for a in _state.get("assets") or []: for asset in _state["assets"]:
if a.get("name") == name and a.get("url"): if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]):
return a return dict(asset)
return None return None
def get_status() -> Dict[str, Any]: def get_status() -> Dict[str, Any]:
"""Снимок состояния после run_check (для подписей в настройках).""" state = _state
return dict(_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]]: def relaunch_env() -> Dict[str, str]:
new_assets = _state.get("assets") or [] """Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6."""
if not new_assets: env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))}
return None mei = getattr(sys, "_MEIPASS", None)
if mei:
target_name = None env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
for var in ("PATH", "LD_LIBRARY_PATH", "GI_TYPELIB_PATH", "GTK_PATH",
# SHA256 match "GST_PLUGIN_PATH", "GIO_MODULE_DIR"):
try: original = env.pop(var + "_ORIG", None)
data, code = fetch_release_by_tag(f"v{current_version}") if original is not None:
if code == 200 and data: env[var] = original
cur_assets = _extract_assets(data) elif mei and env.get(var):
if cur_assets: bundle = os.path.normcase(os.path.abspath(mei))
h = hashlib.sha256() kept = [p for p in env[var].split(os.pathsep)
with open(exe_path, "rb") as f: if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)]
while True: if kept:
chunk = f.read(65536) env[var] = os.pathsep.join(kept)
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: else:
target_name = "TgWsProxy_windows_7_32bit.exe" env.pop(var, None)
return env
for a in new_assets:
if a.get("name") == target_name:
return a["url"], a["name"]
return None
+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)
+40 -109
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import ctypes import ctypes
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -40,14 +39,13 @@ from utils.win32_theme import (
) )
from utils.tray_common import ( from utils.tray_common import (
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE, 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, ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
quit_ctk, release_lock, restart_proxy, quit_ctk, release_lock, restart_proxy,
save_config, start_proxy, stop_proxy, tg_proxy_url, save_config, start_proxy, stop_proxy, tg_proxy_url,
) )
from utils.update_check import ( from utils.update_check import find_asset, get_status, RELEASES_PAGE_URL
download_asset, get_status, get_update_asset, run_check, RELEASES_PAGE_URL, from utils.windows_update import apply_update, asset_name
)
from ui.ctk_tray_ui import ( from ui.ctk_tray_ui import (
install_tray_config_buttons, install_tray_config_form, install_tray_config_buttons, install_tray_config_form,
populate_first_run_window, tray_settings_scroll_and_footer, 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( 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, release_url: Optional[str] = None,
) -> str: ) -> str:
title = title or t("app.name") title = title or t("app.name")
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): 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) result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
if result == _IDYES: if result == _IDYES:
return "update" _perform_update(asset)
if result == _IDNO: if result == _IDNO:
return "open" return "open"
return "close" return "close"
@@ -145,8 +145,8 @@ def update_ctk_form(
root = create_ctk_toplevel( root = create_ctk_toplevel(
ctk, ctk,
title=title, title=title,
width=310 if IS_FROZEN else 210, width=310 if asset else 210,
height=130 if IS_FROZEN else 100, height=130 if asset else 100,
theme=theme, theme=theme,
topmost=False, topmost=False,
after_create=lambda r: r.iconbitmap(ICON_PATH), after_create=lambda r: r.iconbitmap(ICON_PATH),
@@ -183,21 +183,16 @@ def update_ctk_form(
done.set() done.set()
def _on_update() -> None: def _on_update() -> None:
if not download_url:
if release_url:
webbrowser.open(release_url)
_close_with("open")
return
for b in btns: for b in btns:
b.configure(state="disabled") b.configure(state="disabled")
root.protocol("WM_DELETE_WINDOW", lambda: None) root.protocol("WM_DELETE_WINDOW", lambda: None)
def _run(): 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: [b.configure(state="normal") for b in btns])
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close"))) root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
if IS_FROZEN: if asset:
btn_upd = ctk.CTkButton( btn_upd = ctk.CTkButton(
row, text=t("button.update"), width=88, height=34, row, text=t("button.update"), width=88, height=34,
font=(theme.ui_font_family, 13), command=_on_update, font=(theme.ui_font_family, 13), command=_on_update,
@@ -226,81 +221,37 @@ def update_ctk_form(
return result["value"] 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: def _step(msg: str) -> None:
log.info("Update: %s", msg) log.info("Update: %s", msg)
if set_status: if set_status:
set_status(msg) set_status(msg)
time.sleep(0.8)
def _err(msg: str) -> None: def _before_restart() -> None:
log.error("Update error: %s", msg) nonlocal restarting
if set_status: _step(t("update.restarting"))
set_status(f"{t('update.error', msg=msg)}") restarting = True
else: stop_proxy()
_show_error(msg) release_lock()
_release_win_mutex()
_step(t("update.downloading")) _step(t("update.downloading"))
cur_exe = Path(sys.executable)
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
try: try:
log.info("Downloading update from %s", download_url) apply_update(asset, _before_restart)
tmp_path = download_asset(download_url, cur_exe.parent)
except Exception as exc: 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 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) os._exit(0)
@@ -321,13 +272,15 @@ def _trigger_update_flow(icon=None, item=None) -> None:
st = get_status() st = get_status()
if not st.get("has_update"): if not st.get("has_update"):
return return
if _tray_icon is not None:
_tray_icon.menu = _build_menu()
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
ver = st.get("latest") or "?" 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( choice = update_ctk_form(
t("update.available", version=ver), t("update.available", version=ver),
download_url=asset[0] if asset else None, asset=asset,
release_url=url, release_url=url,
) )
if choice == "open": 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() 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) # autostart (registry)
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
@@ -616,7 +548,7 @@ def run_tray() -> None:
return return
start_proxy(_config, _show_error) 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() _show_first_run()
check_ipv6_warning(_show_info) check_ipv6_warning(_show_info)
@@ -635,12 +567,11 @@ def main() -> None:
if IS_FROZEN: if IS_FROZEN:
def _cleanup_old_exes(): def _cleanup_old_exes():
exe_dir = Path(sys.executable).parent
time.sleep(3) time.sleep(3)
for _f in exe_dir.glob("*_oldtgws.exe"): exe = Path(sys.executable)
backup = exe.with_name(exe.stem + "_oldtgws.exe")
try: try:
_f.unlink() backup.unlink(missing_ok=True)
log.info("Deleted leftover: %s", _f)
except OSError: except OSError:
pass pass
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start() threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()