zapret-kvn/xray_fluent/updates/update_signal.py
loop-uh b97087c041
Some checks failed
Windows project source guards / test (push) Failing after 33s
Обновление не мешает игре: общее определение экрана, быстрее после игры
- Определение «на экране игра» — из общего ядра zapret_update.screen (то
  же, что у Zapret 2 GUI): игры «окно без рамки» и память об игре на 10
  минут — свернул игру и ушёл в Discord, а обновление ждёт.
- Пока человек в игре, программа раз в 5 с смотрит только на экран
  (было: полная проверка выпуска раз в 3 минуты) и проверяет выпуск,
  когда он освободился; занятая программа переспрашивает сервер раз в 15 с.
- Разрешение сервера не теряется: пока оно действует, получасовая проверка
  не пропускается и повторяет сорвавшуюся установку.
- Паузы после ошибок сети — не дольше 3 минут (было 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:49:43 +03:00

176 lines
7.4 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.

"""Связка слушателя сервера с окном программы.
Слушатель (``release_watch.ReleaseWatcher``) живёт в фоновом потоке и про Qt
не знает. Здесь его разрешения превращаются в сигнал Qt, а окно получает
простые ответы: ведёт ли сервер очередь, разрешена ли версия, занят ли
человек и что сказать серверу после обновления.
"""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from ..constants import APP_VERSION
from .auto_update import WHY_RE
from .release_watch import (
ACTIVITY_FULLSCREEN,
ACTIVITY_TRAY,
ACTIVITY_WINDOW,
ReleaseWatcher,
fullscreen_app_active,
screen_state,
)
# Как часто окно сообщает слушателю, видно ли оно и включено ли подключение.
# Слушатель читает это из своего потока и Qt не трогает.
PRESENCE_REFRESH_MS = 15 * 1000
# Сколько действует разрешение сервера: в этот срок программа повторяет
# сорвавшуюся установку сама (второго разрешения сервер не пришлёт).
GRANT_LIFETIME_S = 60 * 60
class UpdateSignal(QObject):
"""Разрешения сервера на обновление — для окна программы."""
# Сервер разрешил обновиться до этой версии.
granted = pyqtSignal(str)
# Версия вышла, но очередь на скачивание до программы ещё не дошла.
queued = pyqtSignal(str)
def __init__(
self,
*,
is_enabled: Callable[[], bool],
window_shown: Callable[[], bool],
connected: Callable[[], bool],
parent: QObject | None = None,
watcher_factory: Callable[..., ReleaseWatcher] = ReleaseWatcher,
) -> None:
super().__init__(parent)
self._window_shown = window_shown
self._connected = connected
self._lock = threading.Lock()
self._presence: dict[str, bool] = {}
self._granted_at: dict[str, float] = {}
# Версии, которые надо поставить сразу: установленную сервер отозвал.
self._urgent: set[str] = set()
self._report: dict[str, str] = {}
self._watcher = watcher_factory(
current_version=APP_VERSION,
on_release=self._on_release,
on_queued=lambda version: self.queued.emit(str(version)),
on_revoked=self._on_revoked,
is_enabled=is_enabled,
activity=self._activity,
pending_report=self._pending_report,
report_delivered=self._report_delivered,
)
self._timer = QTimer(self)
self._timer.setInterval(PRESENCE_REFRESH_MS)
self._timer.timeout.connect(self.refresh_presence)
def start(self) -> None:
from zapret_update import screen
self.refresh_presence()
self._timer.start()
self._watcher.start()
# Память об игре: экран проверяется и между вопросами серверу.
screen.start_sampler()
def stop(self) -> None:
self._timer.stop()
self._watcher.stop()
# --- для окна (поток интерфейса) ---
def refresh_presence(self) -> None:
"""Запоминает, видно ли окно и включено ли подключение."""
try:
presence = {"window": bool(self._window_shown()), "connected": bool(self._connected())}
except Exception:
return
with self._lock:
self._presence = presence
def queue_reachable(self) -> bool:
"""Сервер ведёт очередь: ставить версию можно только с его разрешения."""
return self._watcher.reachable is True
def is_granted(self, version: str) -> bool:
with self._lock:
return str(version) in self._granted_at
def has_live_grant(self, now: float | None = None) -> bool:
"""Есть ли разрешение сервера, которое ещё действует (час с момента выдачи)."""
moment = time.time() if now is None else now
with self._lock:
return any(0 <= moment - granted < GRANT_LIFETIME_S for granted in self._granted_at.values())
def granted_at(self, version: str) -> float:
with self._lock:
return float(self._granted_at.get(str(version), 0.0))
@staticmethod
def busy_reason() -> str:
"""Почему обновляться сейчас не время; пусто — можно."""
return "на экране игра или видео на весь экран" if fullscreen_app_active() else ""
def set_report(self, report: dict | None) -> None:
"""Что сказать серверу об исходе прошлого обновления (один раз)."""
with self._lock:
self._report = {str(key): str(value) for key, value in (report or {}).items()}
def note_miss(self, version: str, why: str) -> None:
"""Версия, которую программа взялась ставить сама, не скачалась.
Серверу уходит ``miss`` с причиной: раздачу это не держит, но видно,
почему у людей срывается скачивание. Неотправленный исход прошлого
обновления («дошла», «не вышло») не затирается.
"""
code = str(why or "").strip().lower()
if not (version and WHY_RE.fullmatch(code)):
return
with self._lock:
if not self._report:
self._report = {"miss": str(version), "why": code}
# --- для слушателя (его поток) ---
def is_urgent(self, version: str) -> bool:
with self._lock:
return str(version) in self._urgent
def _on_revoked(self, version: str) -> None:
with self._lock:
self._urgent.add(str(version))
self._on_release(version)
def _on_release(self, version: str) -> None:
with self._lock:
self._granted_at.setdefault(str(version), time.time())
self.granted.emit(str(version))
def _activity(self) -> dict:
with self._lock:
presence = dict(self._presence)
if not presence:
return {}
if fullscreen_app_active():
act = ACTIVITY_FULLSCREEN
else:
act = ACTIVITY_WINDOW if presence["window"] else ACTIVITY_TRAY
# scr — сырой ответ Windows о занятости экрана, только общим счётом.
return {"act": act, "run": "1" if presence["connected"] else "0", "scr": screen_state()}
def _pending_report(self) -> dict:
with self._lock:
return dict(self._report)
def _report_delivered(self, _report: dict) -> None:
with self._lock:
self._report = {}