mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 11:08:44 +00:00
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""
|
|
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 http.client import HTTPException
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from utils.update_check import download_asset, relaunch_env
|
|
|
|
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,
|
|
env=relaunch_env(),
|
|
).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", 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", str(path)]
|
|
raise ValueError("no install command for kind: {0}".format(kind))
|
|
|
|
|
|
def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
|
|
"""Stage beside the executable so an interrupted copy cannot destroy it."""
|
|
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]:
|
|
if log:
|
|
log.info("Linux update: %s", " ".join(cmd))
|
|
proc = subprocess.run(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
timeout=_INSTALL_TIMEOUT_SEC,
|
|
env=relaunch_env(),
|
|
)
|
|
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.
|
|
"""
|
|
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)
|
|
|
|
if log:
|
|
log.info("Linux update (%s): downloading %s", kind, url)
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
|
|
tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
|
|
if kind != "binary":
|
|
return _run_install(install_cmd(kind, tmp), log=log)
|
|
if in_place:
|
|
os.chmod(str(tmp), 0o755)
|
|
os.replace(str(tmp), str(exe))
|
|
return None
|
|
return _run_install(replace_binary_cmd(tmp, exe), log=log)
|
|
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
|
|
return str(exc)
|