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