src/webui/services/system.py
Rama main · réplica de lectura del repositorio original.
"""System metadata and update checks."""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
import aiohttp
from src.version import DEFAULT_UPDATE_REPOSITORY, __version__
from src.runtime_diagnostics import build_runtime_log_archive
from src.runtime_logging import clear_runtime_logs as clear_logs
from src.runtime_logging import runtime_log_status as log_status
logger = logging.getLogger("trpg")
GITHUB_API = "https://api.github.com"
_VERSION_RE = re.compile(
r"^\s*[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\s*$"
)
class NoReleaseError(RuntimeError):
"""Raised when the repository exists but has no public releases yet."""
@dataclass(frozen=True)
class SystemDependencies:
data_dir: Path
config_state: Callable[[], dict[str, Any]]
proxy_url: Callable[[], str]
mirrors: Callable[[], Any | None]
def runtime_log_status(data_dir: Path) -> dict[str, Any]:
return log_status(data_dir)
def clear_runtime_logs(data_dir: Path) -> dict[str, Any]:
return clear_logs(data_dir)
def export_runtime_logs(data_dir: Path) -> tuple[bytes, int]:
return build_runtime_log_archive(data_dir)
def _parse_version(value: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
match = _VERSION_RE.match(value or "")
if not match:
return None
major, minor, patch, prerelease = match.groups()
return (
(int(major), int(minor or 0), int(patch or 0)),
tuple(prerelease.split(".")) if prerelease else None,
)
def _compare_prerelease(left: tuple[str, ...] | None, right: tuple[str, ...] | None) -> int:
if left is None or right is None:
if left is right:
return 0
return 1 if left is None else -1
for left_part, right_part in zip(left, right):
if left_part == right_part:
continue
left_numeric = left_part.isdigit()
right_numeric = right_part.isdigit()
if left_numeric and right_numeric:
return 1 if int(left_part) > int(right_part) else -1
if left_numeric != right_numeric:
return -1 if left_numeric else 1
return 1 if left_part > right_part else -1
return (len(left) > len(right)) - (len(left) < len(right))
def is_newer_version(latest: str, current: str) -> bool:
latest_version = _parse_version(latest)
current_version = _parse_version(current)
if latest_version is None or current_version is None:
return False
latest_core, latest_prerelease = latest_version
current_core, current_prerelease = current_version
if latest_core != current_core:
return latest_core > current_core
return _compare_prerelease(latest_prerelease, current_prerelease) > 0
async def check_updates(
dependencies: SystemDependencies,
include_prerelease: bool | None = None,
) -> dict[str, Any]:
repo = str(os.getenv("TRPG_UPDATE_REPOSITORY") or DEFAULT_UPDATE_REPOSITORY).strip()
if not repo or "/" not in repo:
return {
"ok": False,
"error": "更新仓库配置无效",
"current_version": __version__,
"repository": repo,
"update_available": False,
}
if include_prerelease is None:
config_state = dependencies.config_state()
include_prerelease = str(config_state.get("update_channel") or "stable") == "preview"
channel = "preview" if include_prerelease else "stable"
check_source: dict[str, Any] = {"mode": "github-api", "mirror_name": "GitHub API"}
try:
proxy_url = dependencies.proxy_url()
release, check_source = await _fetch_release_with_mirrors(
dependencies.mirrors(), repo, include_prerelease, proxy_url,
)
except NoReleaseError as exc:
return {
"ok": True,
"message": str(exc),
"current_version": __version__,
"repository": repo,
"update_available": False,
"no_release": True,
"channel": channel,
"releases_url": f"https://github.com/{repo}/releases",
"source_url": f"https://github.com/{repo}",
"install_hint": _install_hint(repo),
"check_source": check_source,
}
except Exception as exc:
logger.warning("检查更新失败: %s", exc)
return {
"ok": False,
"error": str(exc),
"current_version": __version__,
"repository": repo,
"update_available": False,
}
latest_version = str(release.get("tag_name") or "").lstrip("vV")
latest = {
"version": latest_version,
"tag_name": release.get("tag_name", ""),
"name": release.get("name", ""),
"body": release.get("body", ""),
"html_url": release.get("html_url", f"https://github.com/{repo}/releases"),
"published_at": release.get("published_at", ""),
"prerelease": bool(release.get("prerelease")),
"assets": [
{
"name": asset.get("name", ""),
"download_url": asset.get("browser_download_url", ""),
"size": asset.get("size", 0),
}
for asset in release.get("assets", [])
if isinstance(asset, dict)
],
}
return {
"ok": True,
"current_version": __version__,
"repository": repo,
"update_available": is_newer_version(latest_version, __version__),
"no_release": False,
"channel": channel,
"latest": latest,
"release_url": latest["html_url"],
"releases_url": f"https://github.com/{repo}/releases",
"source_url": f"https://github.com/{repo}",
"install_hint": _install_hint(repo),
"check_source": check_source,
}
async def _fetch_release(repo: str, include_prerelease: bool, proxy_url: str = "") -> dict[str, Any]:
endpoint = f"{GITHUB_API}/repos/{repo}/releases"
if not include_prerelease:
endpoint += "/latest"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "DiceFrame update checker",
}
timeout = aiohttp.ClientTimeout(total=12)
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
request_kwargs = {"proxy": proxy_url} if proxy_url else {}
async with session.get(endpoint, **request_kwargs) as resp:
data = await resp.json(content_type=None)
if resp.status == 404:
raise NoReleaseError("暂无公开 Release")
if resp.status >= 400:
message = data.get("message") if isinstance(data, dict) else await resp.text()
raise RuntimeError(f"GitHub 返回 HTTP {resp.status}: {message}")
if not include_prerelease:
return data
if not isinstance(data, list):
raise RuntimeError("GitHub Release 返回格式异常")
for release in data:
if isinstance(release, dict) and not release.get("draft"):
return release
raise RuntimeError("没有可用的 Release")
async def _fetch_release_with_mirrors(
mirrors: Any | None,
repo: str,
include_prerelease: bool,
proxy_url: str = "",
) -> tuple[dict[str, Any], dict[str, Any]]:
api_path = f"/repos/{repo}/releases"
if not include_prerelease:
api_path += "/latest"
if mirrors is not None:
result = await mirrors.fetch_github_api(api_path, official_first=True)
if result.ok and isinstance(result.data, str):
try:
data = await _parse_release_payload(result.data, include_prerelease)
source = result.to_dict()
source.pop("data", None)
return data, {"mode": "mirror", **source}
except Exception as exc:
logger.warning("镜像源返回的 Release 数据无法解析,回退 GitHub API:%s", exc)
logger.warning("通过镜像源检查更新失败,回退 GitHub API:%s", result.error)
release = await _fetch_release(repo, include_prerelease, proxy_url)
return release, {"mode": "github-api", "mirror_name": "GitHub API"}
async def _parse_release_payload(payload: str, include_prerelease: bool) -> dict[str, Any]:
import json
data = json.loads(payload)
if isinstance(data, dict) and data.get("message") == "Not Found":
raise NoReleaseError("暂无公开 Release")
if not include_prerelease:
if not isinstance(data, dict):
raise RuntimeError("GitHub Release 返回格式异常")
return data
if not isinstance(data, list):
raise RuntimeError("GitHub Release 返回格式异常")
for release in data:
if isinstance(release, dict) and not release.get("draft"):
return release
raise RuntimeError("没有可用的 Release")
def _install_hint(repo: str) -> dict[str, str]:
return {
"windows": "下载新版源码包或 Release 附件,保留 data/ 目录后替换程序文件,再重新运行 web_ui.bat。",
"docker": "Compose 部署请先进入存放 docker-compose.yml 的目录,再拉取镜像并重新 docker compose up -d;docker run 部署需重新创建容器,源码镜像需用新版源码重新 build。",
"source": f"源码用户可从 https://github.com/{repo}/releases 下载新版,升级前先备份 data/。",
}
class SystemService:
"""Runtime diagnostics and release checks with explicit host dependencies."""
def __init__(self, dependencies: SystemDependencies) -> None:
self._dependencies = dependencies
async def check_updates(
self, include_prerelease: bool | None = None,
) -> dict[str, Any]:
return await check_updates(self._dependencies, include_prerelease)
def runtime_log_status(self) -> dict[str, Any]:
return runtime_log_status(self._dependencies.data_dir)
def clear_runtime_logs(self) -> dict[str, Any]:
return clear_runtime_logs(self._dependencies.data_dir)
def export_runtime_logs(self) -> tuple[bytes, int]:
return export_runtime_logs(self._dependencies.data_dir)