Files
ComfyUI_Skills_OpenClaw/scripts/shared/frontend_update.py
T

221 lines
7.5 KiB
Python

from __future__ import annotations
import json
import logging
import shutil
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
logger = logging.getLogger(__name__)
FRONTEND_REPO = "HuangYuChuh/ComfyUI_Skills_OpenClaw-frontend"
GITHUB_API_URL = f"https://api.github.com/repos/{FRONTEND_REPO}/releases/tags/latest"
FRONTEND_ARCHIVE_NAME = "frontend-dist.tar.gz"
VERSION_ASSET_NAME = "version.json"
CACHE_TTL = 600 # 10 minutes
_cache: dict[str, object] = {}
class FrontendReleaseUpdateProvider:
"""Update prebuilt frontend assets from the rolling GitHub release."""
def __init__(self, static_dir: Path) -> None:
self._static_dir = static_dir
def check(self) -> dict:
return check_frontend_update(self._static_dir)
def update(self) -> dict:
return perform_frontend_update(self._static_dir)
def check_frontend_update(static_dir: Path) -> dict:
"""Compare local frontend version against the latest rolling release."""
local = _read_local_version(static_dir)
if not local:
return {"has_update": False, "error": "no_local_version"}
remote = _fetch_remote_version()
if not remote:
return {"has_update": False, "error": "fetch_failed"}
local_commit = local.get("commit", "")
remote_commit = remote.get("commit", "")
if not local_commit or not remote_commit:
return {"has_update": False, "error": "missing_commit"}
has_update = local_commit != remote_commit
return {
"has_update": has_update,
"local_commit": local_commit[:8],
"remote_commit": remote_commit[:8],
"remote_date": remote.get("date", ""),
}
def perform_frontend_update(static_dir: Path) -> dict:
"""Download the latest frontend build and replace ui/static atomically."""
local = _read_local_version(static_dir) or {}
release = _fetch_release()
if not release:
return {"success": False, "message": "Failed to fetch latest frontend release"}
asset = next(
(item for item in release.get("assets", []) if item.get("name") == FRONTEND_ARCHIVE_NAME),
None,
)
if not asset:
return {"success": False, "message": f"{FRONTEND_ARCHIVE_NAME} not found in latest release"}
download_url = asset.get("browser_download_url")
if not download_url:
return {"success": False, "message": f"{FRONTEND_ARCHIVE_NAME} has no download URL"}
parent_dir = static_dir.parent
parent_dir.mkdir(parents=True, exist_ok=True)
backup_dir = parent_dir / f"{static_dir.name}.bak"
swap_dir = parent_dir / f"{static_dir.name}.next"
try:
with tempfile.TemporaryDirectory(prefix="openclaw-frontend-") as tmpdir:
tmp_path = Path(tmpdir)
archive_path = tmp_path / FRONTEND_ARCHIVE_NAME
extract_dir = tmp_path / "static"
_download_to_path(download_url, archive_path)
extract_dir.mkdir()
_extract_archive(archive_path, extract_dir)
_validate_static_dir(extract_dir)
_swap_static_dir(extract_dir, static_dir, swap_dir, backup_dir)
except (OSError, tarfile.TarError, ValueError, urllib.error.URLError) as exc:
logger.error("Failed to update frontend assets: %s", exc)
return {"success": False, "message": str(exc)}
updated = _read_local_version(static_dir) or {}
return {
"success": True,
"component": "frontend",
"commit_before": str(local.get("commit", ""))[:8],
"commit_after": str(updated.get("commit", ""))[:8],
"message": "Frontend assets updated",
}
def _read_local_version(static_dir: Path) -> dict | None:
version_file = static_dir / "version.json"
if not version_file.is_file():
return None
try:
return json.loads(version_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
def _fetch_remote_version() -> dict | None:
release = _fetch_release()
if not release:
return None
now = time.monotonic()
# Try to find version.json in release assets
assets = release.get("assets", [])
version_asset = next(
(item for item in assets if item.get("name") == VERSION_ASSET_NAME),
None,
)
if version_asset:
try:
download_url = version_asset["browser_download_url"]
result = _download_json(download_url)
_cache["remote_version"] = result
_cache["cached_at"] = now
return result
except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError) as exc:
logger.debug("Failed to download version.json asset: %s", exc)
# Fallback: parse commit from release body
body = release.get("body", "")
for line in body.splitlines():
if "Commit:" in line:
commit = line.split("`")[-2] if "`" in line else line.split(":")[-1].strip()
result = {"commit": commit, "date": release.get("published_at", "")}
_cache["remote_version"] = result
_cache["cached_at"] = now
return result
return None
def _fetch_release() -> dict | None:
now = time.monotonic()
cached = _cache.get("release")
cached_at = _cache.get("cached_at", 0.0)
if cached and isinstance(cached_at, float) and now - cached_at < CACHE_TTL:
return cached # type: ignore[return-value]
try:
req = urllib.request.Request(
GITHUB_API_URL,
headers={"Accept": "application/vnd.github+json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
release = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
logger.debug("Failed to fetch latest release: %s", exc)
return None
_cache["release"] = release
_cache["cached_at"] = now
return release
def _download_json(url: str) -> dict:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
def _download_to_path(url: str, path: Path) -> None:
req = urllib.request.Request(url, headers={"Accept": "application/octet-stream"})
with urllib.request.urlopen(req, timeout=60) as resp, path.open("wb") as out:
shutil.copyfileobj(resp, out)
def _extract_archive(archive_path: Path, extract_dir: Path) -> None:
with tarfile.open(archive_path, "r:gz") as archive:
for member in archive.getmembers():
target_path = (extract_dir / member.name).resolve()
if not str(target_path).startswith(str(extract_dir.resolve())):
raise ValueError(f"Unsafe archive entry: {member.name}")
archive.extractall(extract_dir)
def _validate_static_dir(static_dir: Path) -> None:
index_file = static_dir / "index.html"
if not index_file.is_file():
raise ValueError("Downloaded frontend bundle is missing index.html")
def _swap_static_dir(source_dir: Path, target_dir: Path, swap_dir: Path, backup_dir: Path) -> None:
shutil.rmtree(swap_dir, ignore_errors=True)
shutil.rmtree(backup_dir, ignore_errors=True)
shutil.copytree(source_dir, swap_dir, dirs_exist_ok=True)
try:
if target_dir.exists():
target_dir.rename(backup_dir)
swap_dir.rename(target_dir)
shutil.rmtree(backup_dir, ignore_errors=True)
except OSError:
if backup_dir.exists() and not target_dir.exists():
backup_dir.rename(target_dir)
raise