Files
pi-pm3/app/backend/managers/update_manager.py
michael a9acdb85ce Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching
Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction
(saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install
substage, renumber all subsequent substages. Switch CI PM3 build to native
ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly
base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL,
BASE_IMAGE, and APT_PROXY env vars in build-image.sh.

Also includes prior Phase 5 work: theme system, design system integration,
component update system, OS updates, CI build pipeline, and test results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:01:01 -08:00

1035 lines
40 KiB
Python

"""Update Manager for Dangerous Pi.
Handles checking for updates from GitHub releases, downloading,
and applying updates to the system. Supports per-component updates
for pm3, frontend, backend, and theme components.
"""
import asyncio
import hashlib
import json
import os
import platform
import re
import shutil
import sys
import tempfile
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List
import aiohttp
from .. import config
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class UpdateStatus(str, Enum):
"""Update status enum."""
IDLE = "idle"
CHECKING = "checking"
AVAILABLE = "available"
DOWNLOADING = "downloading"
INSTALLING = "installing"
COMPLETE = "complete"
FAILED = "failed"
ROLLING_BACK = "rolling_back"
class ComponentId(str, Enum):
"""Known updateable component identifiers."""
PM3 = "pm3"
FRONTEND = "frontend"
BACKEND = "backend"
THEME = "theme"
# ---------------------------------------------------------------------------
# Component manifest dataclasses
# ---------------------------------------------------------------------------
@dataclass
class ComponentInfo:
"""Information about an installed component."""
component_id: str
version: str
installed_at: str
checksum: Optional[str] = None
install_path: str = ""
platform: Optional[str] = None
python_version: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SystemManifest:
"""Complete manifest of all installed components."""
schema_version: int = 1
system_version: str = "0.1.0"
components: Dict[str, ComponentInfo] = field(default_factory=dict)
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
last_updated: str = ""
# ---------------------------------------------------------------------------
# Release / progress dataclasses
# ---------------------------------------------------------------------------
@dataclass
class AvailableComponent:
"""A component update available for download."""
component_id: str
current_version: Optional[str]
available_version: str
download_url: str
asset_name: str
asset_size: int
checksum: str
changelog: str
platform: Optional[str] = None
python_version: Optional[str] = None
compatible: bool = True
incompatible_reason: Optional[str] = None
@dataclass
class ReleaseInfo:
"""GitHub release information."""
version: str
tag_name: str
published_at: str
changelog: str
is_prerelease: bool
components: Dict[str, AvailableComponent] = field(default_factory=dict)
# Legacy fields for backward compatibility
download_url: str = ""
asset_name: str = ""
asset_size: int = 0
checksum: Optional[str] = None
@dataclass
class ComponentProgress:
"""Progress for a single component update."""
component_id: str
status: UpdateStatus
download_progress: float = 0.0
error_message: Optional[str] = None
@dataclass
class UpdateProgress:
"""Update progress information."""
status: UpdateStatus
current_version: str
available_version: Optional[str] = None
download_progress: float = 0.0
error_message: Optional[str] = None
last_check: Optional[str] = None
component_progress: Dict[str, ComponentProgress] = field(default_factory=dict)
active_component: Optional[str] = None
class UpdateManager:
"""Manages system updates from GitHub releases.
Supports per-component updates for pm3, frontend, backend, and theme.
Tracks installed component versions via a manifest file.
"""
MANIFEST_PATH = Path(config.COMPONENT_MANIFEST_PATH)
BACKUP_BASE = Path("/opt/dangerous-pi/backups")
INSTALL_DIR = Path("/opt/dangerous-pi")
def __init__(self):
"""Initialize the update manager."""
self._current_version = config.VERSION
self._github_repo = config.GITHUB_REPO
self._github_api_base = "https://api.github.com"
self._status = UpdateStatus.IDLE
self._progress = UpdateProgress(
status=UpdateStatus.IDLE,
current_version=self._current_version
)
self._latest_release: Optional[ReleaseInfo] = None
self._available_components: Dict[str, AvailableComponent] = {}
self._update_lock = asyncio.Lock()
self._download_path: Optional[Path] = None
self._check_interval = config.UPDATE_CHECK_INTERVAL
# Load or create component manifest
self._manifest = self._load_manifest()
self._current_version = self._manifest.system_version
# ------------------------------------------------------------------
# Manifest I/O
# ------------------------------------------------------------------
def _load_manifest(self) -> SystemManifest:
"""Load component manifest from disk, migrating from legacy if needed."""
if self.MANIFEST_PATH.exists():
try:
data = json.loads(self.MANIFEST_PATH.read_text())
return self._parse_manifest(data)
except (json.JSONDecodeError, KeyError, TypeError) as e:
print(f"Warning: failed to parse manifest, migrating: {e}")
return self._migrate_from_legacy()
def _parse_manifest(self, data: Dict[str, Any]) -> SystemManifest:
"""Parse a manifest dict into a SystemManifest."""
components = {}
for comp_id, comp_data in data.get("components", {}).items():
components[comp_id] = ComponentInfo(
component_id=comp_data.get("component_id", comp_id),
version=comp_data.get("version", "unknown"),
installed_at=comp_data.get("installed_at", ""),
checksum=comp_data.get("checksum"),
install_path=comp_data.get("install_path", ""),
platform=comp_data.get("platform"),
python_version=comp_data.get("python_version"),
metadata=comp_data.get("metadata", {}),
)
return SystemManifest(
schema_version=data.get("schema_version", 1),
system_version=data.get("system_version", config.VERSION),
components=components,
plugins=data.get("plugins", {}),
last_updated=data.get("last_updated", ""),
)
def _save_manifest(self) -> None:
"""Write the current manifest to disk."""
self._manifest.last_updated = datetime.utcnow().isoformat()
data = {
"schema_version": self._manifest.schema_version,
"system_version": self._manifest.system_version,
"components": {
comp_id: asdict(comp)
for comp_id, comp in self._manifest.components.items()
},
"plugins": self._manifest.plugins,
"last_updated": self._manifest.last_updated,
}
# Ensure parent directory exists
self.MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True)
self.MANIFEST_PATH.write_text(json.dumps(data, indent=2))
def _migrate_from_legacy(self) -> SystemManifest:
"""Create a component manifest from legacy VERSION files."""
# Read legacy system version
legacy_version_file = self.INSTALL_DIR / "VERSION"
if legacy_version_file.exists():
system_version = legacy_version_file.read_text().strip()
else:
system_version = config.VERSION
# Detect installed PM3 version
pm3_home = Path(config.PM3_CLIENT_PATH).parent.parent
pm3_version_file = pm3_home / "VERSION.txt"
pm3_version = "unknown"
if pm3_version_file.exists():
pm3_version = pm3_version_file.read_text().strip().split("\n")[0]
now = datetime.utcnow().isoformat()
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
manifest = SystemManifest(
schema_version=1,
system_version=system_version,
components={
ComponentId.PM3: ComponentInfo(
component_id=ComponentId.PM3,
version=pm3_version,
installed_at=now,
install_path=str(pm3_home),
platform=f"{platform.machine()}-linux",
python_version=py_version,
metadata={"migrated_from_legacy": True},
),
ComponentId.FRONTEND: ComponentInfo(
component_id=ComponentId.FRONTEND,
version=system_version,
installed_at=now,
install_path=str(self.INSTALL_DIR / "app" / "frontend" / "build"),
metadata={"migrated_from_legacy": True},
),
ComponentId.BACKEND: ComponentInfo(
component_id=ComponentId.BACKEND,
version=system_version,
installed_at=now,
install_path=str(self.INSTALL_DIR / "app" / "backend"),
metadata={"migrated_from_legacy": True},
),
ComponentId.THEME: ComponentInfo(
component_id=ComponentId.THEME,
version=system_version,
installed_at=now,
install_path=str(self.INSTALL_DIR / "app" / "frontend" / "themes"),
metadata={"migrated_from_legacy": True},
),
},
last_updated=now,
)
self._manifest = manifest
self._save_manifest()
print(f"✅ Migrated to component manifest (system v{system_version})")
return manifest
# ------------------------------------------------------------------
# Manifest accessors
# ------------------------------------------------------------------
def get_manifest(self) -> SystemManifest:
"""Return the current system manifest."""
return self._manifest
def get_component_version(self, component_id: str) -> Optional[str]:
"""Get the installed version of a component."""
comp = self._manifest.components.get(component_id)
return comp.version if comp else None
@staticmethod
def get_system_info() -> Dict[str, str]:
"""Detect the current system's platform and Python version."""
return {
"arch": platform.machine(),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
"python_abi": f"cp{sys.version_info.major}{sys.version_info.minor}",
"os": "linux",
}
# ------------------------------------------------------------------
# Periodic checks
# ------------------------------------------------------------------
async def start_periodic_checks(self):
"""Start periodic update checks in background."""
while True:
try:
await self.check_for_updates()
await asyncio.sleep(self._check_interval)
except asyncio.CancelledError:
break
except Exception as e:
print(f"Error in periodic update check: {e}")
await asyncio.sleep(self._check_interval)
# ------------------------------------------------------------------
# Check for updates (component-aware)
# ------------------------------------------------------------------
async def check_for_updates(self) -> Dict[str, Any]:
"""Check for available updates from GitHub.
Compares each component's installed version against the latest
release manifest. Returns per-component update availability.
"""
async with self._update_lock:
try:
self._status = UpdateStatus.CHECKING
self._progress.status = UpdateStatus.CHECKING
release = await self._fetch_latest_release()
if not release:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
self._progress.last_check = datetime.utcnow().isoformat()
return {
"update_available": False,
"current_version": self._current_version,
"message": "No releases found",
"components": [],
}
# Trigger plugin hooks (non-critical)
try:
from .plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("update_check")
except Exception:
pass
# Check for plugin updates
plugin_updates = []
try:
from .plugin_manager import get_plugin_manager
pm = get_plugin_manager()
plugin_update_infos = await pm.check_plugin_updates()
for pu in plugin_update_infos:
plugin_updates.append({
"plugin_id": pu.plugin_id,
"current_version": pu.current_version,
"available_version": pu.available_version,
"changelog": pu.changelog,
"download_size": pu.download_size,
"source_repo": pu.source_repo,
})
except Exception as e:
print(f"Warning: plugin update check failed: {e}")
# Build per-component update list
self._latest_release = release
self._available_components = {}
component_updates = []
any_update = False
for comp_id, available in release.components.items():
installed_ver = self.get_component_version(comp_id)
available.current_version = installed_ver
if installed_ver and not self._is_newer_version(
available.available_version, installed_ver
):
continue # Already up to date
self._available_components[comp_id] = available
any_update = True
component_updates.append({
"component_id": comp_id,
"current_version": installed_ver,
"available_version": available.available_version,
"changelog": available.changelog,
"download_size": available.asset_size,
"compatible": available.compatible,
"incompatible_reason": available.incompatible_reason,
})
# Also check system-level version (legacy compat)
system_update = self._is_newer_version(
release.version, self._current_version
)
now = datetime.utcnow().isoformat()
self._progress.last_check = now
if any_update or system_update:
self._status = UpdateStatus.AVAILABLE
self._progress.status = UpdateStatus.AVAILABLE
self._progress.available_version = release.version
else:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
return {
"update_available": any_update or system_update
or len(plugin_updates) > 0,
"current_version": self._current_version,
"latest_version": release.version,
"release_date": release.published_at,
"changelog": release.changelog,
"is_prerelease": release.is_prerelease,
"download_size": release.asset_size,
"components": component_updates,
"plugins": plugin_updates,
}
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
raise Exception(f"Failed to check for updates: {e}")
# ------------------------------------------------------------------
# Download (component-level and legacy)
# ------------------------------------------------------------------
async def download_component(self, component_id: str) -> bool:
"""Download a specific component update."""
async with self._update_lock:
available = self._available_components.get(component_id)
if not available:
raise ValueError(f"No update available for {component_id}")
if not available.compatible:
raise ValueError(
f"Component {component_id} is not compatible: "
f"{available.incompatible_reason}"
)
try:
self._progress.active_component = component_id
comp_progress = ComponentProgress(
component_id=component_id,
status=UpdateStatus.DOWNLOADING,
)
self._progress.component_progress[component_id] = comp_progress
self._status = UpdateStatus.DOWNLOADING
self._progress.status = UpdateStatus.DOWNLOADING
temp_dir = Path(tempfile.mkdtemp(
prefix=f"dangerous-pi-{component_id}-"
))
download_path = temp_dir / available.asset_name
async with aiohttp.ClientSession() as session:
async with session.get(available.download_url) as response:
if response.status != 200:
raise Exception(
f"Download failed with status {response.status}"
)
total_size = int(
response.headers.get('content-length', 0)
)
downloaded = 0
with open(download_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
pct = (downloaded / total_size) * 100
comp_progress.download_progress = pct
self._progress.download_progress = pct
# Verify checksum
if available.checksum:
if not self._verify_checksum(download_path, available.checksum):
raise Exception("Checksum verification failed")
comp_progress.download_progress = 100.0
comp_progress.status = UpdateStatus.IDLE
self._progress.download_progress = 100.0
# Store path for install step
available.metadata = {"download_path": str(download_path)} # type: ignore[attr-defined]
return True
except Exception as e:
comp_progress.status = UpdateStatus.FAILED
comp_progress.error_message = str(e)
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
if 'temp_dir' in locals():
shutil.rmtree(temp_dir, ignore_errors=True)
raise Exception(f"Failed to download {component_id}: {e}")
async def download_update(self) -> bool:
"""Download all available component updates (legacy compat)."""
for comp_id in list(self._available_components.keys()):
available = self._available_components[comp_id]
if available.compatible:
await self.download_component(comp_id)
return True
# ------------------------------------------------------------------
# Install (component-level and legacy)
# ------------------------------------------------------------------
async def install_component(self, component_id: str) -> bool:
"""Install a downloaded component update."""
async with self._update_lock:
available = self._available_components.get(component_id)
if not available:
raise ValueError(f"No update available for {component_id}")
download_path_str = getattr(available, 'metadata', {}).get(
'download_path'
) if hasattr(available, 'metadata') else None
if not download_path_str:
raise ValueError(f"Component {component_id} not downloaded yet")
download_path = Path(download_path_str)
if not download_path.exists():
raise ValueError(f"Download file missing for {component_id}")
try:
self._progress.active_component = component_id
comp_progress = self._progress.component_progress.get(
component_id,
ComponentProgress(component_id=component_id,
status=UpdateStatus.INSTALLING)
)
comp_progress.status = UpdateStatus.INSTALLING
self._progress.component_progress[component_id] = comp_progress
self._status = UpdateStatus.INSTALLING
self._progress.status = UpdateStatus.INSTALLING
# Backup current component
await self._backup_component(component_id)
# Extract to staging directory
staging_dir = Path(tempfile.mkdtemp(
prefix=f"dangerous-pi-stage-{component_id}-"
))
await self._run_command(
f"tar -xzf {download_path} -C {staging_dir}"
)
# Run component-specific installer
installer = self._get_installer(component_id)
await installer(staging_dir, available)
# Update manifest
now = datetime.utcnow().isoformat()
comp_info = self._manifest.components.get(
component_id, ComponentInfo(
component_id=component_id,
version="unknown",
installed_at=now,
)
)
comp_info.version = available.available_version
comp_info.installed_at = now
comp_info.checksum = available.checksum
self._manifest.components[component_id] = comp_info
self._save_manifest()
# Cleanup
shutil.rmtree(download_path.parent, ignore_errors=True)
shutil.rmtree(staging_dir, ignore_errors=True)
comp_progress.status = UpdateStatus.COMPLETE
self._status = UpdateStatus.COMPLETE
self._progress.status = UpdateStatus.COMPLETE
# Remove from available
self._available_components.pop(component_id, None)
return True
except Exception as e:
comp_progress.status = UpdateStatus.FAILED
comp_progress.error_message = str(e)
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
# Attempt rollback
try:
await self.rollback_component(component_id)
except Exception as rb_err:
print(f"Rollback failed for {component_id}: {rb_err}")
raise Exception(f"Failed to install {component_id}: {e}")
async def install_update(self) -> bool:
"""Install all downloaded component updates (legacy compat)."""
for comp_id in list(self._available_components.keys()):
await self.install_component(comp_id)
# Update system version
if self._latest_release:
self._current_version = self._latest_release.version
self._manifest.system_version = self._latest_release.version
self._progress.current_version = self._latest_release.version
self._save_manifest()
return True
# ------------------------------------------------------------------
# Component-specific installers
# ------------------------------------------------------------------
def _get_installer(self, component_id: str):
"""Return the installer function for a component."""
installers = {
ComponentId.PM3: self._install_pm3,
ComponentId.FRONTEND: self._install_frontend,
ComponentId.BACKEND: self._install_backend,
ComponentId.THEME: self._install_theme,
}
installer = installers.get(component_id)
if not installer:
raise ValueError(f"Unknown component: {component_id}")
return installer
async def _install_pm3(self, staging_dir: Path, info: AvailableComponent):
"""Install PM3 client + firmware from pre-built binary."""
pm3_home = Path(config.PM3_CLIENT_PATH).parent.parent
# The tarball extracts to pm3/ subdirectory
source = staging_dir / "pm3"
if not source.exists():
# Try finding the extracted directory
dirs = [d for d in staging_dir.iterdir() if d.is_dir()]
source = dirs[0] if dirs else staging_dir
# Copy files preserving structure
for item in source.rglob("*"):
if item.is_file():
rel = item.relative_to(source)
dest = pm3_home / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)
# Ensure _pm3.so symlink exists
pyscripts = pm3_home / "client" / "pyscripts"
pm3_so = pyscripts / "_pm3.so"
lib_so = pyscripts / "libpm3rrg_rdv4.so"
if lib_so.exists() and not pm3_so.exists():
pm3_so.symlink_to("libpm3rrg_rdv4.so")
# Make executables executable
client_bin = pm3_home / "client" / "proxmark3"
if client_bin.exists():
client_bin.chmod(0o755)
for flash_script in pm3_home.glob("pm3-flash-*"):
flash_script.chmod(0o755)
async def _install_frontend(self, staging_dir: Path, info: AvailableComponent):
"""Install frontend build output."""
target = self.INSTALL_DIR / "app" / "frontend" / "build"
source = staging_dir / "frontend" / "build"
if not source.exists():
source = staging_dir / "frontend"
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
async def _install_backend(self, staging_dir: Path, info: AvailableComponent):
"""Install backend Python source and update dependencies."""
target = self.INSTALL_DIR / "app" / "backend"
source = staging_dir / "backend" / "app" / "backend"
if not source.exists():
source = staging_dir / "backend"
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
# Update pip dependencies if requirements.txt included
req_file = staging_dir / "backend" / "requirements.txt"
if req_file.exists():
shutil.copy2(req_file, self.INSTALL_DIR / "requirements.txt")
try:
await self._run_command(
f"pip install -r {self.INSTALL_DIR / 'requirements.txt'} "
f"--quiet --disable-pip-version-check"
)
except Exception as e:
print(f"Warning: pip install failed: {e}")
async def _install_theme(self, staging_dir: Path, info: AvailableComponent):
"""Install theme token CSS files."""
target = self.INSTALL_DIR / "app" / "frontend" / "themes"
source = staging_dir / "theme"
if not source.exists():
source = staging_dir
target.mkdir(parents=True, exist_ok=True)
# Copy theme directories (dt/, classic/, supra/, themes.json)
for item in source.iterdir():
dest = target / item.name
if item.name == "COMPONENT_VERSION":
continue
if item.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(item, dest)
elif item.is_file():
shutil.copy2(item, dest)
# ------------------------------------------------------------------
# Backup & rollback
# ------------------------------------------------------------------
async def _backup_component(self, component_id: str) -> Path:
"""Backup current component before installing update."""
backup_dir = self.BACKUP_BASE / component_id
if backup_dir.exists():
shutil.rmtree(backup_dir)
backup_dir.mkdir(parents=True, exist_ok=True)
comp = self._manifest.components.get(component_id)
if comp and comp.install_path:
source = Path(comp.install_path)
if source.exists():
shutil.copytree(source, backup_dir / "files",
dirs_exist_ok=True)
# Save component info for rollback
info_file = backup_dir / "component-info.json"
info_file.write_text(json.dumps(asdict(comp), indent=2))
return backup_dir
async def rollback_component(self, component_id: str) -> bool:
"""Rollback a component to its backup."""
backup_dir = self.BACKUP_BASE / component_id
if not backup_dir.exists():
raise ValueError(f"No backup found for {component_id}")
self._status = UpdateStatus.ROLLING_BACK
self._progress.status = UpdateStatus.ROLLING_BACK
info_file = backup_dir / "component-info.json"
if not info_file.exists():
raise ValueError(f"Backup metadata missing for {component_id}")
old_info = json.loads(info_file.read_text())
target = Path(old_info["install_path"])
# Restore files
backup_files = backup_dir / "files"
if backup_files.exists():
if target.exists():
shutil.rmtree(target)
shutil.copytree(backup_files, target)
# Restore manifest entry
self._manifest.components[component_id] = ComponentInfo(**old_info)
self._save_manifest()
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
return True
# ------------------------------------------------------------------
# Progress & release notes
# ------------------------------------------------------------------
async def get_progress(self) -> UpdateProgress:
"""Get current update progress."""
return self._progress
async def get_release_notes(self, version: Optional[str] = None) -> str:
"""Get release notes for a specific version."""
try:
if version:
release = await self._fetch_release_by_version(version)
else:
release = await self._fetch_latest_release()
return release.changelog if release else "No release notes available"
except Exception as e:
raise Exception(f"Failed to get release notes: {e}")
# ------------------------------------------------------------------
# GitHub API
# ------------------------------------------------------------------
async def _fetch_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch latest release from GitHub API."""
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/latest"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(
f"GitHub API returned status {response.status}"
)
data = await response.json()
return await self._parse_release_data(data, session)
async def _fetch_release_by_version(self, version: str) -> Optional[ReleaseInfo]:
"""Fetch specific release by version tag."""
tag = version if version.startswith('v') else f'v{version}'
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/tags/{tag}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(
f"GitHub API returned status {response.status}"
)
data = await response.json()
return await self._parse_release_data(data, session)
async def _parse_release_data(
self, data: Dict[str, Any], session: aiohttp.ClientSession
) -> ReleaseInfo:
"""Parse GitHub release API response.
Supports two formats:
1. New-style: release has a release-manifest.json asset
2. Legacy: release has a single .tar.gz asset
"""
version = data['tag_name'].lstrip('v')
assets = data.get('assets', [])
# Look for release-manifest.json (new-style)
manifest_asset = None
legacy_asset = None
for asset in assets:
name = asset['name']
if name == 'release-manifest.json':
manifest_asset = asset
elif name.endswith('.tar.gz') and legacy_asset is None:
legacy_asset = asset
release = ReleaseInfo(
version=version,
tag_name=data['tag_name'],
published_at=data['published_at'],
changelog=data.get('body', ''),
is_prerelease=data.get('prerelease', False),
)
if manifest_asset:
# New-style: parse component manifest
await self._parse_component_manifest(
manifest_asset, assets, release, session
)
elif legacy_asset:
# Legacy: single tarball
release.download_url = legacy_asset['browser_download_url']
release.asset_name = legacy_asset['name']
release.asset_size = legacy_asset['size']
return release
async def _parse_component_manifest(
self,
manifest_asset: Dict[str, Any],
all_assets: List[Dict[str, Any]],
release: ReleaseInfo,
session: aiohttp.ClientSession,
) -> None:
"""Download and parse release-manifest.json, populating release.components."""
manifest_url = manifest_asset['browser_download_url']
async with session.get(manifest_url) as resp:
if resp.status != 200:
return
manifest_data = await resp.json()
# Build asset URL lookup
asset_urls = {a['name']: a for a in all_assets}
sys_info = self.get_system_info()
for comp_id, comp_data in manifest_data.get("components", {}).items():
comp_ver = comp_data.get("version", release.version)
comp_assets = comp_data.get("assets", {})
changelog = comp_data.get("changelog", "")
# Select the right asset variant for this system
selected_key, selected_asset = self._select_asset(
comp_id, comp_assets, sys_info
)
if not selected_asset:
continue
filename = selected_asset.get("filename", "")
gh_asset = asset_urls.get(filename, {})
available = AvailableComponent(
component_id=comp_id,
current_version=self.get_component_version(comp_id),
available_version=comp_ver,
download_url=gh_asset.get("browser_download_url", ""),
asset_name=filename,
asset_size=selected_asset.get("size", gh_asset.get("size", 0)),
checksum=selected_asset.get("checksum_sha256", ""),
changelog=changelog,
platform=selected_asset.get("platform"),
python_version=selected_asset.get("python_version"),
compatible=True,
)
# Sum total asset size for legacy compat
release.asset_size += available.asset_size
release.components[comp_id] = available
# Check for incompatible pm3 variants
if ComponentId.PM3 not in release.components:
# Check if pm3 exists but no compatible variant
pm3_data = manifest_data.get("components", {}).get(ComponentId.PM3)
if pm3_data and pm3_data.get("assets"):
release.components[ComponentId.PM3] = AvailableComponent(
component_id=ComponentId.PM3,
current_version=self.get_component_version(ComponentId.PM3),
available_version=pm3_data.get("version", release.version),
download_url="",
asset_name="",
asset_size=0,
checksum="",
changelog=pm3_data.get("changelog", ""),
compatible=False,
incompatible_reason=(
f"No binary for {sys_info['arch']}-linux "
f"with Python {sys_info['python_version']}"
),
)
def _select_asset(
self,
component_id: str,
assets: Dict[str, Any],
sys_info: Dict[str, str],
) -> tuple:
"""Select the correct asset variant for this system."""
if not assets:
return None, None
# For pm3, match platform + python ABI
if component_id == ComponentId.PM3:
key = f"{sys_info['arch']}-linux-{sys_info['python_abi']}"
if key in assets:
return key, assets[key]
return None, None
# For other components, use "default" key or first available
if "default" in assets:
return "default", assets["default"]
# Take the first (and usually only) asset
first_key = next(iter(assets))
return first_key, assets[first_key]
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def _is_newer_version(self, version1: str, version2: str) -> bool:
"""Compare two version strings. Returns True if version1 > version2."""
def parse_version(v: str) -> tuple:
v = v.lstrip('v')
parts = re.split(r'[-+]', v)[0]
return tuple(map(int, parts.split('.')))
try:
return parse_version(version1) > parse_version(version2)
except (ValueError, AttributeError):
# Fall back to string comparison for non-semver (e.g. PM3 tags)
return version1 != version2
def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool:
"""Verify SHA256 checksum of a file."""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
data = f.read(65536)
if not data:
break
sha256.update(data)
return sha256.hexdigest().lower() == expected_checksum.lower()
async def _run_command(self, command: str) -> str:
"""Run a shell command asynchronously."""
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise Exception(f"Command failed: {stderr.decode()}")
return stdout.decode()
# Global update manager instance
_update_manager: Optional[UpdateManager] = None
def get_update_manager() -> UpdateManager:
"""Get the global update manager instance."""
global _update_manager
if _update_manager is None:
_update_manager = UpdateManager()
return _update_manager