feat: автообновление на Linux без ручной установки пакета (#1223)

This commit is contained in:
Nikita Kolesnikov
2026-10-10 22:01:46 +03:00
committed by GitHub
parent 5f8820d41f
commit a6177fec87
11 changed files with 771 additions and 21 deletions
+11
View File
@@ -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**
+11
View File
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
## Обновление
Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию:
- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля);
- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`.
Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически.
Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза.
## Настройка Telegram Desktop
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
+90 -6
View File
@@ -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)
+241
View File
@@ -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()
+49
View File
@@ -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()
+93
View File
@@ -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()
+2
View File
@@ -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...",
+2
View File
@@ -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": "Перезапуск...",
+180
View File
@@ -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
View File
@@ -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
View File
@@ -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)