131 lines
5.0 KiB
Python
131 lines
5.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Автообновление лаунчера: проверка манифеста, скачивание установщика.
|
|
|
|
Манифест на сервере — /update.json:
|
|
{"version": "2.1.3", "url": "/download/PixelLauncherSetup2.1.3.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
|