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

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