zapret-kvn/xray_fluent/updates/release_watch.py
loop-uh 9b2860b851
Some checks failed
Windows project source guards / test (push) Failing after 26s
Обновление: очередь и проверка выпуска идут под открытым именем сервера (git2, git3, …), выбор один
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:17:58 -04:00

333 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сигнал сервера о новой версии: «длинный запрос».
Программа спрашивает сервер: «я на версии X — есть новее?». Сервер не
отвечает, пока новой версии нет, и отвечает в момент её выхода. Так
обновление приходит за минуты, а не при очередной проверке раз в полчаса.
Сигнал — это ещё и разрешение. Если все программы пойдут за архивом в 140 МБ
разом, сервер встанет, поэтому очередь на скачивание ведёт он: «можно
обновляться» говорит в меру своего канала, остальным — «вы в очереди»
(``queued``; место хранит «талон», который программа называет в следующих
вопросах). Новую версию сервер раздаёт по ступеням: сначала малой доле
программ, остальным — когда первые обновились и снова вышли на связь
(``held``). Если версия не ставится, раздача останавливается сама.
Вместе с вопросом программа называет одно слово о себе (окно открыто, в трее,
на экране игра) и включено ли подключение, а после обновления — с какой
версии пришла и сколько оно заняло. Адресов, имён и настроек в вопросе нет.
Ответ несёт только номер версии. Откуда качать и какая у архива контрольная
сумма, программа по-прежнему узнаёт сама у Forgejo: ложный сигнал ничего
установить не может.
Сервера с очередью может не быть (старый сервер, блокировка): тогда
программа живёт как раньше и проверяет обновления сама.
Модуль не импортирует Qt: слушатель работает в своём фоновом потоке.
"""
from __future__ import annotations
import json
import logging
import sys
import threading
import time
from collections.abc import Callable
from urllib.parse import parse_qs, urlencode, urlsplit
from urllib.request import Request
from zapret_update import screen
from ..network import browser_fetch
from ..network.http_utils import urlopen
from . import site_names
from .app_updater import USER_AGENT, _is_newer_version, _parse_semver
_log = logging.getLogger(__name__)
WAIT_URL = "https://git2.zapret.moe/api/zapret/kvn/wait"
CHANNEL = "stable"
# Вопрос без ожидания: сервер отвечает сразу — так видно, что он на месте.
PROBE_TIMEOUT_S = 15
# Сервер держит вопрос до четырёх с половиной минут; ждём немного дольше.
READ_TIMEOUT_S = 330
# Честный ответ «новостей нет» приходит не раньше срока ожидания. Быстрый
# пустой ответ — признак неисправности: без паузы слушатель завалил бы сервер.
QUICK_ANSWER_S = 20.0
QUICK_ANSWER_PAUSE_S = 120.0
FIRST_FAILURE_PAUSE_S = 60.0
# Сеть вернулась — программа узнаёт это не позже чем через три минуты.
MAX_FAILURE_PAUSE_S = 3 * 60.0
DISABLED_RECHECK_S = 60.0
# Занятая программа спрашивает короче: вышла из игры — и через несколько
# секунд уже просит разрешение по-настоящему.
BUSY_HOLD_S = 15
# В первые минуты после запуска состояние быстро меняется (подключение ещё
# поднимается), поэтому вопрос держится недолго — иначе сервер несколько
# минут помнил бы состояние первых секунд.
WARMUP_S = 120.0
WARMUP_HOLD_S = 45
ACTIVITY_WINDOW = "window"
ACTIVITY_TRAY = "tray"
ACTIVITY_FULLSCREEN = "fullscreen"
# Что на экране — общее ядро (одно правило с Zapret 2 GUI): игра Direct3D,
# презентация, окно на весь экран без рамки (игры «окно без рамки» Windows
# называет обычными окнами) и память об игре на 10 минут — свернул игру и
# ушёл в Discord, а обновление всё равно ждёт.
FOREGROUND_FULL = screen.FOREGROUND_FULL
FOREGROUND_BARE = screen.FOREGROUND_BARE
FOREGROUND_PART = screen.FOREGROUND_PART
FOREGROUND_SHELL = screen.FOREGROUND_SHELL
FOREGROUND_OWN = screen.FOREGROUND_OWN
screen_state = screen.screen_state
def fullscreen_app_active() -> bool:
"""На экране игра, видео или презентация во весь экран — сейчас или недавно.
Обновление перезапускает программу и на несколько секунд рвёт
подключение: посреди игры это вылет из матча.
"""
return screen.fullscreen_active()
def _ask_server(url: str, timeout: float) -> dict:
"""Один вопрос серверу.
Подключение выключено (``run=0``) — запрос идёт напрямую, и провайдер видит
рукопожатие: тогда сетевым стеком Chromium, чтобы ровный опрос нашего
сервера не выдавал приложение отпечатком TLS Python. Подключено — как
раньше, urllib через системный прокси или TUN, то есть внутри туннеля:
прямой путь там был бы заметнее, а не тише.
"""
# Основное имя сервера режут по TLS: спрашиваем под открытым (git2, git3, …).
url = site_names.resolve(url)
connected = parse_qs(urlsplit(url).query).get("run", ["1"])[0] != "0"
if not connected and browser_fetch.SERVICE.available():
try:
reply = browser_fetch.SERVICE.fetch(
url,
headers=(("accept", "application/json"), ("user-agent", USER_AGENT)),
timeout=timeout,
max_bytes=64 * 1024,
)
except browser_fetch.BrowserFetchUnavailable:
pass
else:
if reply.status != 200:
raise OSError(f"сервер ответил HTTP {reply.status}")
return _payload(reply.body)
request = Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
with urlopen(request, timeout=timeout) as response:
return _payload(response.read(64 * 1024))
def _payload(body: bytes) -> dict:
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("сервер ответил не тем, чего ждал слушатель")
return payload
class ReleaseWatcher:
"""Ждёт разрешение сервера обновиться и сообщает о нём один раз на версию.
``on_release(version)`` и ``on_queued(version)`` вызываются из фонового
потока слушателя. ``activity()`` возвращает ``{"act": ..., "run": ...}``,
``pending_report()`` — ``{"prev", "took"}`` либо ``{"fail"}`` для одного
сообщения серверу; после доставки зовётся ``report_delivered``.
"""
def __init__(
self,
*,
current_version: str,
on_release: Callable[[str], None],
on_queued: Callable[[str], None] = lambda _version: None,
on_revoked: Callable[[str], None] | None = None,
is_enabled: Callable[[], bool] = lambda: True,
is_busy: Callable[[], bool] = fullscreen_app_active,
activity: Callable[[], dict] = dict,
pending_report: Callable[[], dict] = dict,
report_delivered: Callable[[dict], None] = lambda _report: None,
ask: Callable[[str, float], dict] = _ask_server,
url: str = WAIT_URL,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._known = str(current_version).lstrip("vV")
self._on_release = on_release
self._on_queued = on_queued
# Выключатель версии (как у Zapret 2 GUI): установленную версию сервер
# отозвал — новая ставится сразу, не дожидаясь, пока человек освободится.
self._on_revoked = on_revoked
self._is_enabled = is_enabled
self._is_busy = is_busy
self._activity = activity
self._pending_report = pending_report
self._report_delivered = report_delivered
self._ask_server = ask
self._url = url
self._clock = clock
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Ответил ли сервер: None — ещё не знаем, идёт первый вопрос.
self._reachable: bool | None = None
self._probed = False
self._queued_version = ""
self._ticket = 0
self._held = ""
self._started_at: float | None = None
@property
def reachable(self) -> bool | None:
"""True — сервер ведёт очередь обновлений для этой программы."""
return self._reachable
@property
def known_version(self) -> str:
return self._known
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self.run, name="update-release-watch", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _pause(self, seconds: float) -> bool:
"""Пауза, которую прерывает остановка. True — пора выходить."""
return self._stop.wait(max(float(seconds), 0.0))
def _call(self, callback: Callable, default):
try:
return callback()
except Exception:
return default
def run(self) -> None:
if _parse_semver(self._known) is None:
# Сборка без нормальной версии сравнивать ничего не может.
self._reachable = False
return
failures = 0
while not self._stop.is_set():
if not self._call(self._is_enabled, False):
self._reachable = False
self._probed = False
if self._pause(DISABLED_RECHECK_S):
return
continue
probe = not self._probed
started = self._clock()
try:
answer = self._ask(probe=probe)
except Exception as exc:
self._probed = False
self._reachable = False
failures += 1
if failures == 1:
_log.info("[update] Очередь обновлений на сервере недоступна (%s): программа проверяет сама", exc)
if self._pause(min(FIRST_FAILURE_PAUSE_S * 2 ** (failures - 1), MAX_FAILURE_PAUSE_S)):
return
continue
failures = 0
self._reachable = True
if probe:
self._probed = True
_log.info("[update] Ждём сигнал сервера о новой версии")
if self._stop.is_set():
return
allowed = self._accept(answer)
if probe or allowed:
continue
if self._clock() - started < QUICK_ANSWER_S and self._pause(QUICK_ANSWER_PAUSE_S):
return
def _ask(self, *, probe: bool) -> dict:
params = {"channel": CHANNEL, "known": self._known}
busy = bool(self._call(self._is_busy, False))
if self._started_at is None:
self._started_at = self._clock()
if probe:
params["hold"] = "0"
elif self._clock() - self._started_at < WARMUP_S:
params["hold"] = str(WARMUP_HOLD_S)
elif busy:
params["hold"] = str(BUSY_HOLD_S)
if busy:
params["busy"] = "1"
if self._ticket:
# С талоном программа стоит по времени первой постановки, а не
# уходит в конец очереди при каждом новом вопросе.
params["ticket"] = str(self._ticket)
told = self._call(self._activity, {})
if isinstance(told, dict):
params.update({key: str(told[key]) for key in ("act", "run", "scr") if told.get(key) not in (None, "")})
report = self._call(self._pending_report, {})
report = {str(key): str(value) for key, value in report.items()} if isinstance(report, dict) else {}
params.update(report)
answer = self._ask_server(f"{self._url}?{urlencode(params)}", PROBE_TIMEOUT_S if probe else READ_TIMEOUT_S)
if report:
self._call(lambda: self._report_delivered(report), None)
return answer
def _newer_version(self, answer: dict) -> str:
version = str(answer.get("version") or "").lstrip("vV")
if _parse_semver(version) is None or not _is_newer_version(version, self._known):
return ""
return version
def _accept(self, answer: dict) -> bool:
"""True — сервер разрешил обновиться до версии новее известной."""
version = self._newer_version(answer)
if not version:
return False
if answer.get("changed"):
# Запоминаем до вызова: об одной версии разрешение приходит один раз.
self._known = version
self._queued_version = ""
self._ticket = 0
if answer.get("revoked") and self._on_revoked is not None:
_log.info("[update] Сервер отозвал установленную версию: ставим v%s сразу", version)
self._notify(self._on_revoked, version)
return True
_log.info("[update] Сервер разрешил обновиться до v%s", version)
self._notify(self._on_release, version)
return True
if answer.get("queued"):
if self._queued_version != version:
self._ticket = 0
if not self._ticket:
try:
self._ticket = max(int(answer.get("ticket") or 0), 0)
except (TypeError, ValueError):
self._ticket = 0
if self._queued_version != version:
self._queued_version = version
_log.info("[update] Вышла версия v%s: ждём очереди на скачивание", version)
self._notify(self._on_queued, version)
return False
held = str(answer.get("held") or "")
if held and self._held != f"{held}:{version}":
self._held = f"{held}:{version}"
reason = {
"stage": "сервер раздаёт её по ступеням, очередь этой программы ещё не подошла",
"halted": "сервер остановил её раздачу",
"busy": "программа занята — обновится, когда освободится",
}.get(held, "сервер просит подождать")
_log.info("[update] Вышла версия v%s: %s", version, reason)
return False
@staticmethod
def _notify(callback: Callable[[str], None], version: str) -> None:
try:
callback(version)
except Exception:
_log.warning("[update] Сигнал о версии v%s не обработан", version, exc_info=True)