Ctrl K
ComunidadGuías…

src/webui/services/updater.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Application self-update service.

The updater deliberately separates downloading from applying. Portable builds
are installed side-by-side and switched by DiceFrameLauncher after a health
check. Source archives use a backup/restore transaction and require a manual
restart.
"""
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
import shutil
import stat
import time
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Awaitable, Callable

from src.docker_launcher.contracts import (
    PLATFORM as DOCKER_PLATFORM,
    PYTHON_ABI as DOCKER_PYTHON_ABI,
    RUNTIME_API as DOCKER_RUNTIME_API,
    safe_extract_package as safe_extract_docker_package,
    validate_package_tree as validate_docker_package_tree,
)
from src.docker_launcher.data_versions import data_for_payload, runtime_for_data
from src.version import __version__

logger = logging.getLogger("trpg")

MAX_APP_UPDATE_BYTES = 200 * 1024 * 1024
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 20_000
DEFAULT_PROBATION_SECONDS = 90
_UPDATER_DIR_NAME = "_updater"
_STATE_FILE_NAME = "state.json"
_RESTART_SIGNAL_NAME = "restart_signal.json"
_ROLLBACK_LAUNCHER_PREFIX = "launcher-rollback-"

_ASSET_PATTERNS = {
    "source": re.compile(r"DiceFrame-v.+windows\.zip$", re.IGNORECASE),
    "portable": re.compile(r"DiceFrame-v.+windows-portable\.zip$", re.IGNORECASE),
    "docker": re.compile(
        r"^DiceFrame-v[0-9A-Za-z][0-9A-Za-z._+-]{0,63}-"
        r"docker-update-linux-amd64\.zip$"
    ),
}


@dataclass(frozen=True)
class UpdaterDependencies:
    data_dir: Path
    root: Path
    mirrors: Any
    check_updates: Callable[[], Awaitable[dict[str, Any]]]
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
_ACTIVE_STATES = {"downloading", "verifying", "applying", "restarting"}
_INTERRUPTIBLE_STATES = {"downloading", "verifying", "applying"}
_PROTECTED_SOURCE_ENTRIES = {
    ".git",
    ".codex",
    ".claude",
    "_updater",
    "data",
    "dist",
    "logs",
}
_BUILTIN_PLUGIN_DIRS = {"examples", "qq-napcat"}


def _migrate_user_plugin_packages(
    source_plugins_dir: Path, packages_dir: Path
) -> None:
    """把旧 plugins/ 里非内置的插件复制到 data/plugin-packages/,跨版本保留。

    用复制而非移动:apply 可能失败回滚,复制保留旧版本目录的插件,回滚到
    旧版本后用户插件仍可用;移动会让旧版本读不到用户插件。跳过 examples
    内置示例与无效目录;目标已存在则跳过(幂等)。
    """
    if not source_plugins_dir.is_dir():
        return
    packages_dir.mkdir(parents=True, exist_ok=True)
    for child in sorted(source_plugins_dir.iterdir()):
        name = child.name
        if name.startswith(".") or name in _BUILTIN_PLUGIN_DIRS:
            continue
        if not child.is_dir() or not (child / "plugin.json").is_file():
            continue
        dest = packages_dir / name
        if dest.exists():
            continue
        shutil.copytree(child, dest)


class UpdateRolledBackError(RuntimeError):
    """An apply transaction failed, but all changed files were restored."""


def _file_sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(64 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def verify_sha256(path: Path, expected: str) -> None:
    normalized = (expected or "").strip().lower()
    if not re.fullmatch(r"[0-9a-f]{64}", normalized):
        raise ValueError(f"SHA-256 格式无效:{path.name}")
    actual = _file_sha256(path)
    if not hmac.compare_digest(actual, normalized):
        raise ValueError(
            f"SHA-256 校验失败:{path.name}(期望 {normalized[:8]}…,实际 {actual[:8]}…)"
        )


def select_asset(
    latest: dict[str, Any], kind: str, expected_version: str = "",
) -> tuple[dict[str, Any], dict[str, Any] | None] | None:
    """Select one exact update asset and its checksum manifest."""
    assets = latest.get("assets") or []
    pattern = _ASSET_PATTERNS.get(kind)
    if not pattern:
        return None
    target: dict[str, Any] | None = None
    for asset in assets:
        name = str(asset.get("name", ""))
        if not name.endswith(".sha256") and pattern.search(name):
            if kind == "docker":
                normalized = expected_version.strip().lstrip("vV")
                expected_name = f"DiceFrame-v{normalized}-docker-update-linux-amd64.zip"
                if not normalized or name != expected_name:
                    continue
            target = asset
            break
    if not target:
        return None
    sha_asset = next((asset for asset in assets if str(asset.get("name", "")) == "SHA256SUMS"), None)
    if sha_asset is None:
        sha_name = str(target.get("name", "")) + ".sha256"
        sha_asset = next(
            (asset for asset in assets if str(asset.get("name", "")) == sha_name),
            None,
        )
    return target, sha_asset


def parse_sha256_file(content: str, filename: str) -> str:
    for line in (content or "").splitlines():
        match = _SHA256_RE.search(line.strip())
        if match:
            return match.group(0)
    raise ValueError(f"无法从校验文件解析 SHA-256:{filename}")


def parse_sha256_manifest(content: str, filename: str) -> str:
    """Return exactly one checksum bound to filename; reject ambiguity."""

    matches: list[str] = []
    for raw in (content or "").splitlines():
        parts = raw.strip().split()
        if len(parts) < 2 or parts[-1].lstrip("*") != filename:
            continue
        digest = parts[0].lower()
        if not re.fullmatch(r"[0-9a-f]{64}", digest):
            raise ValueError(f"校验清单中的 SHA-256 无效:{filename}")
        matches.append(digest)
    if len(matches) != 1:
        raise ValueError(f"校验清单必须且只能包含一条 {filename} 记录")
    return matches[0]


def _path_within(path: Path, parent: Path) -> bool:
    try:
        path.resolve().relative_to(parent.resolve())
        return True
    except ValueError:
        return False


def _extended_length_path(text: str) -> str:
    """把 Windows 绝对路径改写成扩展长度形式(``\\\\?\\`` 前缀)。

    便携版常被解压在多层嵌套目录里,系统又默认没开 LongPathsEnabled:普通路径
    超过 260 字符(MAX_PATH)就打不开,更新包里最深的文件会让解压直接失败
    (FileNotFoundError),哪怕最终装进 ``versions/`` 的路径并不超长——解压临时
    目录比最终位置深得多。扩展长度路径不受这个限制。

    扩展长度路径不再做 ``.``/``..``、正斜杠等规范化,调用方必须传已规范化的
    绝对路径(见 :func:`_io_path`)。
    """
    if text.startswith("\\\\?\\"):
        return text
    if text.startswith("\\\\"):
        return "\\\\?\\UNC\\" + text[2:]
    return "\\\\?\\" + text


def _io_path(path: Path) -> Path:
    """更新包解压、搬移、清理时实际交给文件系统的路径。

    Windows 上换成扩展长度形式;其它平台原样返回。只用于 I/O 调用本身——越界
    检查等路径比较仍用普通路径,免得前缀形式与普通形式互相比不上。
    """
    if os.name != "nt":
        return path
    return Path(_extended_length_path(os.path.abspath(path)))


def _rmtree(path: Path, *, ignore_errors: bool = False) -> None:
    shutil.rmtree(_io_path(path), ignore_errors=ignore_errors)


def _move(source: Path, target: Path) -> None:
    # 跨卷(数据目录单独放在别的盘)时 shutil.move 会退回复制 + 删除,同样要走长路径。
    shutil.move(str(_io_path(source)), str(_io_path(target)))


def _archive_member_path(name: str) -> PurePosixPath:
    normalized = name.replace("\\", "/")
    path = PurePosixPath(normalized)
    if (
        not normalized
        or normalized.startswith("/")
        or re.match(r"^[A-Za-z]:", normalized)
        or any(part in {"", ".", ".."} for part in path.parts)
    ):
        raise ValueError(f"更新包包含不安全路径:{name}")
    return path


def safe_extract_archive(archive: Path, destination: Path) -> Path:
    """Safely extract a release zip and return its single top-level directory."""
    destination.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(archive) as package:
        members = package.infolist()
        if not members or len(members) > MAX_ARCHIVE_MEMBERS:
            raise ValueError("更新包为空或文件数量超出限制")

        total_size = 0
        top_levels: set[str] = set()
        checked: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
        for member in members:
            path = _archive_member_path(member.filename)
            mode = member.external_attr >> 16
            if stat.S_ISLNK(mode):
                raise ValueError(f"更新包不允许符号链接:{member.filename}")
            total_size += member.file_size
            if total_size > MAX_EXTRACTED_BYTES:
                raise ValueError("更新包解压后大小超出限制")
            top_levels.add(path.parts[0])
            checked.append((member, path))

        if len(top_levels) != 1:
            raise ValueError("更新包必须只包含一个顶层目录")

        root = destination.resolve()
        for member, relative in checked:
            target = (destination / Path(*relative.parts)).resolve()
            if not _path_within(target, root):
                raise ValueError(f"更新包路径越界:{member.filename}")
            io_target = _io_path(target)
            if member.is_dir():
                io_target.mkdir(parents=True, exist_ok=True)
                continue
            io_target.parent.mkdir(parents=True, exist_ok=True)
            with package.open(member) as source, io_target.open("wb") as output:
                shutil.copyfileobj(source, output)

    extracted_root = destination / next(iter(top_levels))
    if not extracted_root.is_dir():
        raise ValueError("更新包顶层目录无效")
    return extracted_root


def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_name(path.name + ".tmp")
    temp.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    os.replace(temp, path)


def _safe_version_dir(version: str) -> str:
    normalized = version.strip().lstrip("vV")
    if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z._+-]{0,63}", normalized):
        raise ValueError(f"版本号格式无效:{version}")
    return "v" + normalized


def is_self_update_supported(root: Path) -> dict[str, Any]:
    """Describe whether self-update is supported and which install mode is active."""
    install_mode = os.getenv("TRPG_INSTALL_MODE", "").strip().lower()
    if install_mode == "docker-managed":
        runtime_text = os.getenv("TRPG_DOCKER_RUNTIME_ROOT", "").strip()
        if not runtime_text:
            return {
                "supported": False, "mode": "docker", "reason": "runtime",
                "hint": "当前 Docker 基础镜像缺少托管更新目录配置,请更新基础镜像",
            }
        runtime_root = Path(runtime_text)
        if not runtime_root.is_dir() or not os.access(runtime_root, os.W_OK):
            return {
                "supported": False, "mode": "docker", "reason": "readonly",
                "hint": "Docker 托管更新目录不可写,请检查 data 持久卷权限",
            }
        return {"supported": True, "mode": "docker", "reason": "", "hint": ""}
    if Path("/.dockerenv").exists() or os.getenv("TRPG_DATA_DIR") == "/app/data":
        return {
            "supported": False,
            "mode": "docker",
            "reason": "docker",
            "hint": (
                "Compose 部署请先进入存放 docker-compose.yml 的目录,再运行 "
                "docker compose pull && docker compose up -d;使用 docker run 启动的容器"
                "需要按部署文档拉取镜像并重新创建,NAS 也可在容器管理界面完成更新"
            ),
        }

    install_root_env = os.getenv("TRPG_INSTALL_ROOT", "").strip()
    if install_root_env:
        install_root = Path(install_root_env).resolve()
        if not os.access(install_root, os.W_OK):
            return {
                "supported": False,
                "mode": "portable",
                "reason": "readonly",
                "hint": "便携版安装目录不可写,请移动到可写目录后重试",
            }
        return {"supported": True, "mode": "portable", "reason": "", "hint": ""}

    if (root / ".git").exists():
        return {
            "supported": False,
            "mode": "development",
            "reason": "development",
            "hint": "Git 开发目录不会自动覆盖,请使用 git pull 更新",
        }
    if not os.access(root, os.W_OK):
        return {
            "supported": False,
            "mode": "source",
            "reason": "readonly",
            "hint": "安装目录不可写,请手动下载更新包",
        }
    return {"supported": True, "mode": "source", "reason": "", "hint": ""}


class UpdaterService:
    """Persistent updater state machine."""

    def __init__(self, dependencies: UpdaterDependencies) -> None:
        self._data_dir = dependencies.data_dir.resolve()
        self._dir = runtime_for_data(self._data_dir, os.environ) or self._data_dir / _UPDATER_DIR_NAME
        self._dir.mkdir(parents=True, exist_ok=True)
        self._state_file = self._dir / _STATE_FILE_NAME
        self._restart_signal = self._dir / _RESTART_SIGNAL_NAME
        self._root = dependencies.root.resolve()
        self._mirrors = dependencies.mirrors
        self._check_updates = dependencies.check_updates
        self._state = self._load_state()
        self._task: asyncio.Task | None = None

        state = self._state.get("state")
        if state in _INTERRUPTIBLE_STATES:
            self._state.update(state="failed", error="进程重启,上次更新操作中断")
            self._save_state()
        elif state == "restarting" and not self._restart_signal.exists():
            self._state.update(state="failed", error="更新重启握手文件缺失")
            self._save_state()

    def _load_state(self) -> dict[str, Any]:
        if self._state_file.exists():
            try:
                data = json.loads(self._state_file.read_text(encoding="utf-8"))
                if isinstance(data, dict):
                    return data
            except Exception:
                logger.warning("更新状态文件损坏,已重置", exc_info=True)
        return {"state": "idle"}

    def _save_state(self) -> None:
        _atomic_json(self._state_file, self._state)

    def get_status(self) -> dict[str, Any]:
        # During a portable switchover the launcher owns the terminal state.
        # The candidate/rollback server started while state was "restarting",
        # so refresh its in-memory copy after the launcher commits the result.
        if self._state.get("state") == "restarting":
            persisted = self._load_state()
            if persisted.get("state") != "restarting":
                self._state = persisted
        return {
            **self._state,
            "current_version": __version__,
            "self_update": is_self_update_supported(self._root),
            "rollback": self.get_rollback_status(),
        }

    def is_busy(self) -> bool:
        return self._state.get("state") in _ACTIVE_STATES

    def _read_portable_pointer_dir(
        self, install_root: Path, field: str,
    ) -> Path | None:
        pointer = self._dir / "current.json"
        if not pointer.exists():
            return None
        try:
            payload = json.loads(pointer.read_text(encoding="utf-8"))
            relative = str(payload.get(field) or "")
            versions = (install_root / "versions").resolve()
            candidate = (install_root / relative).resolve()
            if (
                not relative
                or not _path_within(candidate, versions)
                or not (candidate / "app" / "web_server.py").is_file()
                or not (candidate / "python" / "python.exe").is_file()
            ):
                return None
            return candidate
        except (OSError, ValueError, json.JSONDecodeError):
            logger.warning("便携版当前版本指针无效", exc_info=True)
            return None

    @staticmethod
    def _portable_payload_version(directory: Path) -> str:
        version_file = directory / "app" / "src" / "version.py"
        try:
            text = version_file.read_text(encoding="utf-8")
            match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
            if match:
                return match.group(1).strip().lstrip("vV")
        except OSError:
            pass
        return directory.name.strip().lstrip("vV")

    def _read_docker_pointer_dir(self, runtime_root: Path, field: str) -> Path | None:
        pointer = runtime_root / "current.json"
        if not pointer.exists():
            return None
        try:
            payload = json.loads(pointer.read_text(encoding="utf-8"))
            relative = str(payload.get(field) or "")
            versions = (runtime_root / "docker-versions").resolve()
            candidate = (runtime_root / relative).resolve()
            if not relative or not _path_within(candidate, versions):
                return None
            return candidate
        except (OSError, ValueError, json.JSONDecodeError):
            logger.warning("Docker 当前版本指针无效", exc_info=True)
            return None

    def _rollback_target(self) -> tuple[str, Path, str] | None:
        support = is_self_update_supported(self._root)
        if not support.get("supported"):
            return None
        mode = str(support.get("mode") or "")
        if mode == "portable":
            install_root_text = os.getenv("TRPG_INSTALL_ROOT", "").strip()
            if not install_root_text:
                return None
            install_root = Path(install_root_text).resolve()
            current = self._read_portable_pointer_dir(install_root, "relative_dir")
            target = self._read_portable_pointer_dir(install_root, "previous_relative_dir")
            if not current or not target or current == target:
                return None
            version = self._portable_payload_version(target)
            try:
                _safe_version_dir(version)
            except ValueError:
                return None
            return mode, target, version
        if mode == "docker":
            runtime_text = os.getenv("TRPG_DOCKER_RUNTIME_ROOT", "").strip()
            if not runtime_text:
                return None
            runtime_root = Path(runtime_text).resolve()
            current = self._read_docker_pointer_dir(runtime_root, "relative_dir")
            target = self._read_docker_pointer_dir(runtime_root, "previous_relative_dir")
            if not current or not target or current == target:
                return None
            try:
                manifest = validate_docker_package_tree(
                    target,
                    platform=DOCKER_PLATFORM,
                    python_abi=DOCKER_PYTHON_ABI,
                    runtime_api=DOCKER_RUNTIME_API,
                )
                if os.getenv("TRPG_DOCKER_DATA_HOME"):
                    data_for_payload(runtime_root, Path(os.environ["TRPG_DOCKER_DATA_HOME"]).resolve(), target)
            except (OSError, ValueError):
                logger.warning("Docker 上一版本包无效", exc_info=True)
                return None
            return mode, target, str(manifest["version"])
        return None

    def get_rollback_status(self) -> dict[str, Any]:
        support = is_self_update_supported(self._root)
        mode = str(support.get("mode") or "")
        if mode not in {"portable", "docker"}:
            return {
                "available": False,
                "mode": mode,
                "target_version": "",
                "reason": "当前安装方式没有可由 launcher 切换的上一版本",
            }
        target = self._rollback_target()
        if target is None:
            return {
                "available": False,
                "mode": mode,
                "target_version": "",
                "reason": "没有可用的上一版本程序副本",
            }
        return {
            "available": True,
            "mode": target[0],
            "target_version": target[2],
            "reason": "",
            "restores_data": target[0] == "docker" and bool(os.getenv("TRPG_DOCKER_DATA_HOME")),
        }

    async def rollback_update(self) -> dict[str, Any]:
        if self.is_busy():
            return {"ok": False, "error": "已有更新任务在进行中"}
        target = self._rollback_target()
        if target is None:
            return {"ok": False, "error": self.get_rollback_status()["reason"]}

        mode, target_dir, version = target
        if mode == "docker" and os.getenv("TRPG_DOCKER_DATA_HOME"):
            from src.compat.program_rollback_access import prepare_rollback_access

            runtime = Path(os.environ["TRPG_DOCKER_RUNTIME_ROOT"]).resolve()
            home = Path(os.environ["TRPG_DOCKER_DATA_HOME"]).resolve()
            target_data = data_for_payload(runtime, home, target_dir)
            try:
                await asyncio.to_thread(prepare_rollback_access, target_data)
            except (OSError, ValueError) as exc:
                return {"ok": False, "error": f"无法安全准备回退数据:{exc}"}
        signal: dict[str, Any] = {
            "schema": 1,
            "operation": "rollback",
            "mode": mode,
            "expected_version": version,
            "candidate_dir": str(target_dir),
            "relative_dir": target_dir.relative_to(
                Path(os.getenv("TRPG_DOCKER_RUNTIME_ROOT", "")).resolve()
                if mode == "docker"
                else Path(os.getenv("TRPG_INSTALL_ROOT", "")).resolve()
            ).as_posix(),
            "probation_seconds": DEFAULT_PROBATION_SECONDS,
            "created_at": int(time.time()),
        }
        if mode == "portable":
            install_root = Path(os.getenv("TRPG_INSTALL_ROOT", "")).resolve()
            launcher = install_root / "DiceFrame.exe"
            if not launcher.is_file():
                return {"ok": False, "error": "当前便携版启动器不存在,无法安全回退"}
            staged_launcher = self._dir / (
                _ROLLBACK_LAUNCHER_PREFIX + _safe_version_dir(version) + ".exe"
            )
            try:
                shutil.copy2(launcher, staged_launcher)
            except OSError as exc:
                return {"ok": False, "error": f"无法准备回退启动器:{exc}"}
            signal["launcher_path"] = str(staged_launcher)

        self._state.update(
            state="restarting",
            operation="rollback",
            version=version,
            kind=mode,
            candidate_dir=str(target_dir),
            error="",
            restart_needed=False,
        )
        self._save_state()
        _atomic_json(self._restart_signal, signal)
        return {
            "ok": True,
            "state": "restarting",
            "operation": "rollback",
            "version": version,
        }

    async def download_update(self, kind: str) -> dict[str, Any]:
        if self._mirrors is None:
            return {"ok": False, "error": "镜像服务不可用(插件宿主未初始化)"}
        if kind not in _ASSET_PATTERNS:
            return {"ok": False, "error": f"未知的更新包类型:{kind}"}
        if self.is_busy():
            return {"ok": False, "error": "已有更新任务在进行中"}

        support = is_self_update_supported(self._root)
        if not support.get("supported"):
            return {
                "ok": False,
                "error": support.get("hint") or "当前环境不支持自动更新",
            }
        mode = str(support.get("mode", ""))
        if kind != mode:
            expected = "便携版" if mode == "portable" else "完整源码"
            return {"ok": False, "error": f"当前安装方式需要下载{expected}更新包"}

        check = await self._check_updates()
        if not check.get("ok"):
            return {"ok": False, "error": check.get("error", "版本检查失败")}
        latest = check.get("latest") or {}
        if not latest:
            return {"ok": False, "error": "未找到最新版本", "no_release": True}
        version = str(latest.get("version", ""))
        selection = select_asset(latest, kind, version)
        if not selection:
            return {"ok": False, "error": f"未找到 {kind} 类型的更新包"}

        asset, sha_asset = selection
        if kind == "docker":
            if not sha_asset:
                return {"ok": False, "error": "Docker 更新包缺少 SHA256SUMS,已拒绝下载"}
            size = int(asset.get("size", 0) or 0)
            if size <= 0 or size > MAX_APP_UPDATE_BYTES:
                return {"ok": False, "error": "Docker 更新包大小无效"}
        self._task = asyncio.create_task(
            self._run_download(asset, sha_asset, version, kind)
        )
        return {
            "ok": True,
            "state": "downloading",
            "version": version,
            "asset": asset.get("name", ""),
        }

    async def _run_download(
        self,
        asset: dict[str, Any],
        sha_asset: dict[str, Any] | None,
        version: str,
        kind: str,
    ) -> None:
        asset_name = str(asset.get("name", ""))
        asset_url = str(asset.get("download_url", ""))
        target = self._dir / Path(asset_name).name
        partial = target.with_name(target.name + ".part")
        self._state = {
            "state": "downloading",
            "version": version,
            "kind": kind,
            "asset": asset_name,
            "downloaded_bytes": 0,
            "total_bytes": int(asset.get("size", 0) or 0),
            "mirror_used": "",
            "error": "",
            "restart_needed": False,
        }
        self._save_state()
        try:
            expected_sha = ""
            if sha_asset:
                sha_url = str(sha_asset.get("download_url", ""))
                sha_result = await self._mirrors.fetch_github_url(
                    sha_url, binary=False, max_bytes=4096, resolve=False,
                )
                if sha_result.ok and sha_result.data:
                    expected_sha = (
                        parse_sha256_manifest(str(sha_result.data), asset_name)
                        if kind == "docker" or str(sha_asset.get("name", "")) == "SHA256SUMS"
                        else parse_sha256_file(str(sha_result.data), asset_name)
                    )
                else:
                    if kind == "docker":
                        raise ValueError("Docker 更新包校验清单下载失败,已拒绝安装")
                    logger.warning("SHA-256 校验文件下载失败,跳过校验:%s", sha_result.error)
            elif kind == "docker":
                raise ValueError("Docker 更新包必须提供 SHA-256 校验清单")

            def on_progress(downloaded: int, total: int) -> None:
                self._state["downloaded_bytes"] = downloaded
                if total:
                    self._state["total_bytes"] = total

            result = await self._mirrors.download_to_file(
                asset_url,
                partial,
                max_bytes=MAX_APP_UPDATE_BYTES,
                on_progress=on_progress,
                resolve=False,
            )
            if not result.ok:
                self._state.update(state="failed", error=result.error or "下载失败")
                self._save_state()
                return
            self._state["mirror_used"] = result.mirror_name
            if expected_sha:
                self._state["state"] = "verifying"
                self._save_state()
                verify_sha256(partial, expected_sha)
            os.replace(partial, target)
            self._state.update(
                state="staged",
                path=str(target),
                sha256=expected_sha,
                downloaded_at=int(time.time()),
                mirror_used=result.mirror_name,
            )
            self._save_state()
            logger.info(
                "更新包已暂存:%s(版本 %s,镜像 %s)",
                asset_name,
                version,
                result.mirror_name,
            )
        except Exception as exc:
            partial.unlink(missing_ok=True)
            if kind == "docker":
                target.unlink(missing_ok=True)
            self._state.update(state="failed", error=str(exc))
            self._save_state()
            logger.exception("更新下载失败")

    async def apply_update(self) -> dict[str, Any]:
        if self.is_busy():
            return {"ok": False, "error": "已有更新任务在进行中"}
        if self._state.get("state") != "staged":
            return {"ok": False, "error": "没有可应用的已下载更新包"}

        archive = Path(str(self._state.get("path", "")))
        if not archive.is_file() or not _path_within(archive, self._dir):
            return {"ok": False, "error": "已下载更新包不存在或路径无效"}

        support = is_self_update_supported(self._root)
        if not support.get("supported"):
            return {"ok": False, "error": support.get("hint") or "当前环境不支持自动更新"}

        kind = str(self._state.get("kind", ""))
        mode = str(support.get("mode", ""))
        if kind != mode:
            expected = "便携版" if mode == "portable" else "完整源码"
            return {"ok": False, "error": f"当前安装方式需要下载{expected}更新包"}

        version = str(self._state.get("version", ""))
        self._state.update(state="applying", error="", restart_needed=False)
        self._save_state()
        self._task = asyncio.create_task(
            self._run_apply(archive, version, mode)
        )
        return {"ok": True, "state": "applying", "version": version}

    async def _run_apply(self, archive: Path, version: str, mode: str) -> None:
        try:
            if mode == "portable":
                result = await asyncio.to_thread(
                    self._prepare_portable_update, archive, version
                )
                self._remove_completed_archive(archive)
                self._state.update(
                    state="restarting",
                    candidate_dir=result["candidate_dir"],
                    error="",
                    restart_needed=False,
                )
                self._save_state()
                _atomic_json(self._restart_signal, result)
                logger.info("便携版更新已准备,等待启动器切换到 %s", version)
            elif mode == "docker":
                result = await asyncio.to_thread(
                    self._prepare_docker_update, archive, version
                )
                self._remove_completed_archive(archive)
                self._state.update(
                    state="restarting",
                    candidate_dir=result["candidate_dir"],
                    error="",
                    restart_needed=False,
                )
                self._save_state()
                _atomic_json(self._restart_signal, result)
                logger.info("Docker 更新已准备,等待 launcher 切换到 %s", version)
            else:
                backup = await asyncio.to_thread(
                    self._apply_source_update, archive, version
                )
                self._remove_completed_archive(archive)
                self._prune_source_backups(backup)
                self._state.update(
                    state="done",
                    backup_dir=str(backup),
                    applied_at=int(time.time()),
                    error="",
                    restart_needed=True,
                )
                self._save_state()
                logger.info("源码更新已应用,等待手动重启:%s", version)
        except UpdateRolledBackError as exc:
            self._state.update(
                state="rolled-back",
                error=str(exc),
                restart_needed=False,
            )
            self._save_state()
            logger.warning("源码更新失败,已回滚:%s", exc)
        except Exception as exc:
            self._state.update(state="failed", error=str(exc), restart_needed=False)
            self._save_state()
            logger.exception("应用更新失败")

    def _prepare_docker_update(self, archive: Path, version: str) -> dict[str, Any]:
        runtime_text = os.getenv("TRPG_DOCKER_RUNTIME_ROOT", "").strip()
        if not runtime_text:
            raise ValueError("缺少 Docker 托管更新目录")
        runtime_root = Path(runtime_text).resolve()
        if runtime_root != self._dir.resolve():
            raise ValueError("Docker 托管更新目录与应用数据目录不一致")
        versions_dir = runtime_root / "docker-versions"
        versions_dir.mkdir(parents=True, exist_ok=True)
        version_dir = versions_dir / _safe_version_dir(version)
        if not _path_within(version_dir, versions_dir):
            raise ValueError("Docker 候选版本目录越界")

        current = self._read_docker_current_dir(runtime_root)
        if current and current == version_dir.resolve():
            raise ValueError("不能覆盖当前正在运行的 Docker 版本")
        active_text = os.getenv("TRPG_ACTIVE_VERSION_DIR", "").strip()
        if active_text:
            active = Path(active_text).resolve()
            if not _path_within(active, versions_dir):
                raise ValueError("Docker 当前活动版本目录越界")
            if active == version_dir.resolve():
                raise ValueError("不能覆盖当前正在运行的 Docker 版本")
        extract_dir = runtime_root / ("extract-" + _safe_version_dir(version))
        installing = versions_dir / (version_dir.name + ".installing")
        shutil.rmtree(extract_dir, ignore_errors=True)
        shutil.rmtree(installing, ignore_errors=True)
        extract_dir.mkdir(parents=True)
        try:
            package_root = safe_extract_docker_package(archive, extract_dir)
            manifest = validate_docker_package_tree(
                package_root,
                expected_version=version,
                platform=DOCKER_PLATFORM,
                python_abi=DOCKER_PYTHON_ABI,
                runtime_api=DOCKER_RUNTIME_API,
            )
            if version_dir.exists():
                existing_manifest = validate_docker_package_tree(
                    version_dir,
                    expected_version=version,
                    platform=DOCKER_PLATFORM,
                    python_abi=DOCKER_PYTHON_ABI,
                    runtime_api=DOCKER_RUNTIME_API,
                )
                if existing_manifest != manifest:
                    raise ValueError("同版本 Docker 候选已存在且清单不同,已拒绝覆盖")
                manifest = existing_manifest
            else:
                os.replace(package_root, installing)
                os.replace(installing, version_dir)
        finally:
            shutil.rmtree(extract_dir, ignore_errors=True)
            shutil.rmtree(installing, ignore_errors=True)

        legacy_plugins = Path(os.getenv("TRPG_LEGACY_PLUGIN_DIR", "/app/plugins"))
        _migrate_user_plugin_packages(legacy_plugins, self._data_dir / "plugin-packages")
        return {
            "schema": 1,
            "mode": "docker",
            "expected_version": str(manifest["version"]),
            "relative_dir": version_dir.relative_to(runtime_root).as_posix(),
            "candidate_dir": str(version_dir),
            "probation_seconds": int(manifest["probation_seconds"]),
            "created_at": int(time.time()),
        }

    def _read_docker_current_dir(self, runtime_root: Path) -> Path | None:
        pointer = runtime_root / "current.json"
        if not pointer.exists():
            return None
        try:
            payload = json.loads(pointer.read_text(encoding="utf-8"))
            candidate = (runtime_root / str(payload.get("relative_dir") or "")).resolve()
            versions = (runtime_root / "docker-versions").resolve()
            if _path_within(candidate, versions):
                return candidate
        except Exception:
            logger.warning("Docker 当前版本指针无效", exc_info=True)
        return None

    def _remove_completed_archive(self, archive: Path) -> None:
        try:
            archive = archive.resolve()
            if _path_within(archive, self._dir) and archive.is_file():
                archive.unlink()
        except OSError:
            logger.warning("更新成功,但未能删除下载包:%s", archive, exc_info=True)

    def _prune_source_backups(self, keep: Path) -> None:
        try:
            keep = keep.resolve()
            updater_dir = self._dir.resolve()
            for backup in updater_dir.glob("backup-*"):
                try:
                    resolved = backup.resolve()
                    if (
                        resolved != keep
                        and _path_within(resolved, updater_dir)
                        and backup.is_dir()
                    ):
                        _rmtree(backup)
                except OSError:
                    logger.warning(
                        "未能删除旧的源码更新备份:%s", backup, exc_info=True
                    )
        except OSError:
            logger.warning("未能清理旧的源码更新备份", exc_info=True)

    def _prepare_portable_update(
        self, archive: Path, version: str
    ) -> dict[str, Any]:
        install_root_text = os.getenv("TRPG_INSTALL_ROOT", "").strip()
        if not install_root_text:
            raise ValueError("缺少便携版安装根目录")
        install_root = Path(install_root_text).resolve()
        versions_dir = install_root / "versions"
        versions_dir.mkdir(parents=True, exist_ok=True)
        version_dir = versions_dir / _safe_version_dir(version)
        if not _path_within(version_dir, versions_dir):
            raise ValueError("候选版本目录越界")

        extract_dir = self._dir / ("extract-" + _safe_version_dir(version))
        candidate_temp: Path | None = None
        if extract_dir.exists():
            _rmtree(extract_dir)
        extract_dir.mkdir(parents=True)
        try:
            package_root = safe_extract_archive(archive, extract_dir)
            required = (
                package_root / "app" / "web_server.py",
                package_root / "python" / "python.exe",
                package_root / "DiceFrame.exe",
            )
            if not all(path.is_file() for path in required):
                raise ValueError("便携版更新包结构无效")

            current_pointer = self._read_current_version_dir(install_root)
            if version_dir.exists():
                if current_pointer and version_dir.resolve() == current_pointer:
                    raise ValueError("不能覆盖当前正在使用的版本目录")
                _rmtree(version_dir)

            candidate_temp = versions_dir / (version_dir.name + ".installing")
            if candidate_temp.exists():
                _rmtree(candidate_temp)
            candidate_temp.mkdir()
            _move(package_root / "app", candidate_temp / "app")
            _move(package_root / "python", candidate_temp / "python")
            os.replace(candidate_temp, version_dir)

            launcher_staged = self._dir / (
                "launcher-" + _safe_version_dir(version) + ".exe"
            )
            shutil.copy2(package_root / "DiceFrame.exe", launcher_staged)
        finally:
            if candidate_temp is not None and candidate_temp.exists():
                _rmtree(candidate_temp, ignore_errors=True)
            _rmtree(extract_dir, ignore_errors=True)

        # 迁移旧 plugins/ 里的用户插件:同时覆盖 versions 布局的 active 版本
        # 与根目录旧布局(1.9.5 及更早的便携版),旧布局用户插件在根 app/ 下。
        sources = []
        if current_pointer:
            sources.append(current_pointer / "app" / "plugins")
        sources.append(install_root / "app" / "plugins")
        for source in sources:
            _migrate_user_plugin_packages(
                source,
                self._dir.parent / "plugin-packages",
            )

        return {
            "schema": 1,
            "expected_version": version.strip().lstrip("vV"),
            "candidate_dir": str(version_dir),
            "launcher_path": str(launcher_staged),
            "probation_seconds": DEFAULT_PROBATION_SECONDS,
            "created_at": int(time.time()),
        }

    def _read_current_version_dir(self, install_root: Path) -> Path | None:
        pointer = self._dir / "current.json"
        if not pointer.exists():
            return None
        try:
            payload = json.loads(pointer.read_text(encoding="utf-8"))
            relative = str(payload.get("relative_dir", ""))
            candidate = (install_root / relative).resolve()
            versions = (install_root / "versions").resolve()
            if _path_within(candidate, versions):
                return candidate
        except Exception:
            logger.warning("当前版本指针无效", exc_info=True)
        return None

    def _apply_source_update(self, archive: Path, version: str) -> Path:
        extract_dir = self._dir / ("extract-" + _safe_version_dir(version))
        if extract_dir.exists():
            _rmtree(extract_dir)
        extract_dir.mkdir(parents=True)

        timestamp = time.strftime("%Y%m%d-%H%M%S")
        backup_dir = self._dir / f"backup-{timestamp}"
        moved_old: list[tuple[Path, Path]] = []
        installed_new: list[Path] = []
        try:
            package_root = safe_extract_archive(archive, extract_dir)
            if not (
                (package_root / "web_server.py").is_file()
                and (package_root / "src").is_dir()
                and (package_root / "static-v2" / "index.html").is_file()
            ):
                raise ValueError("完整源码更新包结构无效")

            _migrate_user_plugin_packages(
                self._root / "plugins",
                self._dir.parent / "plugin-packages",
            )

            entries = [
                entry
                for entry in package_root.iterdir()
                if entry.name not in _PROTECTED_SOURCE_ENTRIES
            ]
            if not entries:
                raise ValueError("完整源码更新包没有可安装内容")

            backup_dir.mkdir(parents=True)
            for source in entries:
                target = self._root / source.name
                if target.exists():
                    backup_target = backup_dir / source.name
                    _move(target, backup_target)
                    moved_old.append((backup_target, target))

            for source in entries:
                target = self._root / source.name
                _move(source, target)
                installed_new.append(target)
            return backup_dir
        except Exception as original:
            rollback_errors: list[str] = []
            for target in reversed(installed_new):
                try:
                    if target.is_dir():
                        _rmtree(target)
                    elif target.exists():
                        target.unlink()
                except Exception as exc:
                    rollback_errors.append(f"删除新文件 {target.name} 失败:{exc}")
            for backup, target in reversed(moved_old):
                try:
                    if backup.exists():
                        _move(backup, target)
                except Exception as exc:
                    rollback_errors.append(f"恢复旧文件 {target.name} 失败:{exc}")
            if backup_dir.exists() and not any(backup_dir.iterdir()):
                backup_dir.rmdir()
            if rollback_errors:
                raise RuntimeError(
                    f"源码更新失败且回滚不完整:{original};"
                    + ";".join(rollback_errors)
                ) from original
            if moved_old or installed_new:
                raise UpdateRolledBackError(
                    f"源码更新失败,已恢复旧版本:{original}"
                ) from original
            raise
        finally:
            _rmtree(extract_dir, ignore_errors=True)