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

This commit is contained in:
Nikita Kolesnikov authored and GitHub committed 2026-10-10 22:01:46 +03:00
1 parent 5f8820d41f
commit a6177fec87
11 files changed
+771 -21

No files matched your search

+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()