mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 11:08:44 +00:00
Updates refactoring
This commit is contained in:
1 parent
c317406b52
commit
03be9baad1
10 files changed
+731
-542
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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.",
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user