mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 02:58:45 +00:00
162 lines
5.7 KiB
Python
162 lines
5.7 KiB
Python
"""One startup check against GitHub Releases and verified asset downloads."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from http.client import HTTPException
|
|
from itertools import zip_longest
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
from urllib.request import Request
|
|
|
|
from proxy.utils import build_github_opener
|
|
|
|
REPO = "Flowseal/tg-ws-proxy"
|
|
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
|
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
|
|
|
_SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE)
|
|
_state: Dict[str, Any] = {
|
|
"checked": False,
|
|
"has_update": False,
|
|
"ahead_of_release": False,
|
|
"latest": None,
|
|
"html_url": RELEASES_PAGE_URL,
|
|
"error": None,
|
|
"assets": [],
|
|
}
|
|
|
|
|
|
def _parse_version_tuple(s: str) -> tuple:
|
|
parts = []
|
|
for part in (s or "").strip().lstrip("vV").split("."):
|
|
match = re.match(r"\d+", part)
|
|
parts.append(int(match[0]) if match else 0)
|
|
return tuple(parts)
|
|
|
|
|
|
def _version_gt(a: str, b: str) -> bool:
|
|
for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0):
|
|
if x != y:
|
|
return x > y
|
|
return False
|
|
|
|
|
|
def fetch_latest_release(timeout: float = 12.0) -> dict:
|
|
req = Request(RELEASES_LATEST_API, headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "tg-ws-proxy-update-check",
|
|
})
|
|
with build_github_opener().open(req, timeout=timeout) as resp:
|
|
return json.load(resp)
|
|
|
|
|
|
def _extract_assets(data: dict) -> list:
|
|
assets = (data or {}).get("assets") or []
|
|
if not isinstance(assets, list):
|
|
raise ValueError("Invalid release assets from GitHub")
|
|
return [
|
|
{"name": a["name"], "url": a["browser_download_url"],
|
|
"digest": a.get("digest") if isinstance(a.get("digest"), str) else ""}
|
|
for a in assets
|
|
if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"]
|
|
and isinstance(a.get("browser_download_url"), str) and a["browser_download_url"]
|
|
]
|
|
|
|
|
|
def run_check(current_version: str) -> None:
|
|
"""Publish the complete result at once; no persistent cache or error cooldown."""
|
|
global _state
|
|
state = {
|
|
"checked": True, "has_update": False, "ahead_of_release": False,
|
|
"latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [],
|
|
}
|
|
try:
|
|
data = fetch_latest_release()
|
|
if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str):
|
|
raise ValueError("Invalid release from GitHub")
|
|
latest = data["tag_name"].strip().lstrip("vV")
|
|
if not latest or not latest[0].isdigit():
|
|
raise ValueError("Invalid release version from GitHub")
|
|
state.update(
|
|
latest=latest,
|
|
has_update=_version_gt(latest, current_version),
|
|
ahead_of_release=_version_gt(current_version, latest),
|
|
html_url=data.get("html_url") or RELEASES_PAGE_URL,
|
|
assets=_extract_assets(data),
|
|
)
|
|
except (OSError, ValueError, HTTPException) as exc:
|
|
state["error"] = str(exc) or type(exc).__name__
|
|
logging.getLogger(__name__).warning("Update check failed: %s", exc)
|
|
_state = state
|
|
|
|
|
|
def download_asset(
|
|
url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "",
|
|
timeout: float = 30.0,
|
|
) -> Path:
|
|
"""Return a temporary file only after its mandatory SHA-256 has matched."""
|
|
expected = _SHA256.fullmatch(digest if isinstance(digest, str) else "")
|
|
if not expected:
|
|
raise ValueError("The release asset has no valid SHA-256 checksum")
|
|
|
|
fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix, prefix="TgWsProxy_tmp_")
|
|
path = Path(name)
|
|
try:
|
|
sha = hashlib.sha256()
|
|
with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp:
|
|
while True:
|
|
chunk = resp.read(65536)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
sha.update(chunk)
|
|
if sha.hexdigest() != expected[1].lower():
|
|
raise OSError("SHA-256 checksum does not match the release asset")
|
|
return path
|
|
except BaseException:
|
|
with suppress(OSError):
|
|
path.unlink()
|
|
raise
|
|
|
|
|
|
def find_asset(name: str) -> Optional[Dict[str, str]]:
|
|
"""Find an asset suitable for automatic installation."""
|
|
for asset in _state["assets"]:
|
|
if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]):
|
|
return dict(asset)
|
|
return None
|
|
|
|
|
|
def get_status() -> Dict[str, Any]:
|
|
state = _state
|
|
return {**state, "assets": [dict(a) for a in state["assets"]]}
|
|
|
|
|
|
def relaunch_env() -> Dict[str, str]:
|
|
"""Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6."""
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))}
|
|
mei = getattr(sys, "_MEIPASS", None)
|
|
if mei:
|
|
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
|
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
|
|
elif mei and env.get(var):
|
|
bundle = os.path.normcase(os.path.abspath(mei))
|
|
kept = [p for p in env[var].split(os.pathsep)
|
|
if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)]
|
|
if kept:
|
|
env[var] = os.pathsep.join(kept)
|
|
else:
|
|
env.pop(var, None)
|
|
return env
|