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>
1035 lines
40 KiB
Python
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
|