# -*- coding: utf-8 -*- """Автообновление лаунчера: проверка манифеста, скачивание установщика. Манифест на сервере — /update.json: {"version": "2.1.1", "url": "/download/PixelLauncherSetup2.1.1.exe", "sha256": "…", "size": 15418378, "notes": "…"} url относительный (база — UPDATE_BASE), sha256/size необязательны, но sha256 проверяется, если указан. check() — манифест, если версия новее текущей; None — обновления нет; ошибки сети/формата — RuntimeError. download() — скачать установщик и проверить SHA-256, вернуть путь. run_installer() — запустить установщик (caller закрывает лаунчер). """ import hashlib import json import os import sys import tempfile from . import LAUNCHER_VERSION, http # База обновлений — официальный домен (HTTPS, сертификат Let's Encrypt). UPDATE_BASE = "https://pixellauncher.ru" MANIFEST_URL = UPDATE_BASE + "/update.json" def _vtuple(version): """"1.10" -> (1, 10); нечисловые куски — ноль; хвостовые нули срезаем (1.0.0.0 == 1.0).""" out = [] for part in str(version).strip().split("."): part = part.strip() out.append(int(part) if part.isdigit() else 0) while len(out) > 1 and out[-1] == 0: out.pop() return tuple(out) def is_newer(version): """Есть ли в version что-то новее, чем установлено.""" return _vtuple(version) > _vtuple(LAUNCHER_VERSION) def check(timeout=15): """Манифест с версией новее текущей, иначе None. Бросает RuntimeError.""" try: raw = http.get_bytes(MANIFEST_URL, timeout=timeout) manifest = json.loads(raw.decode("utf-8")) except Exception as e: # noqa: BLE001 raise RuntimeError(f"нет связи с сервером обновлений: {e}") from e if not isinstance(manifest, dict) \ or not str(manifest.get("version") or "").strip(): raise RuntimeError("сервер прислал некорректный манифест") if not is_newer(manifest["version"]): return None return manifest def sha256_file(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def _installer_path(url): name = url.split("?")[0].rstrip("/").split("/")[-1] or "update.exe" if not name.lower().endswith(".exe"): name = "update.exe" return os.path.join(tempfile.gettempdir(), name) def download(manifest, progress=None, cancel=None): """Скачивает установщик из манифеста, сверяет SHA-256. Возвращает путь. progress(done_bytes, total_bytes|None); cancel — threading.Event. """ url = str(manifest.get("url") or "") if not url: raise RuntimeError("в манифесте обновления нет url") fetch = url if os.path.isabs(url): from pathlib import Path fetch = Path(url).as_uri() # C:/… -> file:///C:/… elif url.startswith("file:"): fetch = url elif "://" not in url: url = UPDATE_BASE + url # относительный путь /download/… fetch = url path = _installer_path(url) want = str(manifest.get("sha256") or "").strip().lower() size = int(manifest.get("size") or 0) or None if os.path.isfile(path): if want: if sha256_file(path) == want: if progress: total = os.path.getsize(path) progress(total, total) return path try: # битый/устаревший остаток — качаем заново os.remove(path) except OSError: pass elif size and os.path.getsize(path) == size: return path http.download_file(fetch, path, size=size, progress=progress, cancel=cancel) if want: got = sha256_file(path) if got != want: try: os.remove(path) except OSError: pass raise RuntimeError("SHA-256 не совпал — файл обновления битый") return path def run_installer(path): """Запускает установщик. После вызова лаунчер нужно завершить.""" if not os.path.isfile(path): raise RuntimeError("нет файла установщика: " + str(path)) if sys.platform != "win32": raise RuntimeError("автоустановка поддерживается только в Windows") os.startfile(path) # noqa: S606