"""自动升级后的启动健康标记与失败版本熔断记录。""" from __future__ import annotations import json import os import re import time from dataclasses import dataclass from pathlib import Path HEALTH_STATUSES = {"process_started", "main_window_ready", "environment_blocked"} @dataclass(frozen=True) class UpdateHealthContext: install_root: Path transaction_id: str target_version: str @property def transaction_dir(self): return self.install_root / ".cmshopee-update" / "transactions" / self.transaction_id @property def health_path(self): return self.transaction_dir / "health.json" def _atomic_json(path, payload): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") os.replace(str(temporary), str(path)) def context_from_argv(argv, install_root, app_version): values = list(argv or []) try: transaction_index = values.index("--update-transaction") version_index = values.index("--update-target-version") transaction_id = values[transaction_index + 1] target_version = values[version_index + 1] except (ValueError, IndexError): return None if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", transaction_id): return None if target_version != app_version: return None return UpdateHealthContext(Path(install_root).resolve(), transaction_id, target_version) def write_health(context, status, message=""): if context is None: return None if status not in HEALTH_STATUSES: raise ValueError("未知升级健康状态") payload = { "schema_version": 1, "transaction_id": context.transaction_id, "target_version": context.target_version, "status": status, "message": str(message or "")[:300], "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } _atomic_json(context.health_path, payload) return context.health_path def failed_versions_path(install_root): return Path(install_root).resolve() / ".cmshopee-update" / "failed-versions.json" def load_failed_versions(install_root): path = failed_versions_path(install_root) try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return {"schema_version": 1, "releases": {}} releases = payload.get("releases") if not isinstance(releases, dict): releases = {} return {"schema_version": 1, "releases": releases} def failed_release_key(version, sha256): return "%s:%s" % (str(version or "").strip(), str(sha256 or "").strip().lower()) def record_failed_release(install_root, version, sha256, reason): payload = load_failed_versions(install_root) key = failed_release_key(version, sha256) previous = payload["releases"].get(key) or {} payload["releases"][key] = { "version": str(version), "sha256": str(sha256).lower(), "reason": str(reason or "自动升级后新版未能正常启动")[:300], "attempts": int(previous.get("attempts") or 0) + 1, "failed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), } _atomic_json(failed_versions_path(install_root), payload) return payload["releases"][key] def get_failed_release(install_root, version, sha256): payload = load_failed_versions(install_root) return payload["releases"].get(failed_release_key(version, sha256))