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>
This commit is contained in:
michael
2026-03-04 12:01:01 -08:00
parent 2ec89041ef
commit a9acdb85ce
163 changed files with 8124 additions and 921 deletions

View File

@@ -3,13 +3,17 @@
Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService.
"""
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict
from typing import Optional, Dict, List
from .. import config
from ..services.container import container
from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager
from ..managers.os_update_manager import get_os_update_manager
router = APIRouter()
@@ -676,14 +680,14 @@ async def get_ssl_info():
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and "subjectAltName" in result.stdout:
if result.returncode == 0 and ("subjectAltName" in result.stdout or "Subject Alternative Name" in result.stdout):
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
san_line = result.stdout.strip()
for line in san_line.split("\n"):
if "DNS:" in line or "IP:" in line:
if "DNS:" in line or "IP" in line:
# Split by comma and clean up
sans = [s.strip() for s in line.split(",")]
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP"))]
break
# Get SHA256 fingerprint
@@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
import subprocess
import os
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
# Check multiple possible locations for the script
script_candidates = [
"/opt/dangerous-pi/scripts/generate-ssl-cert.sh",
os.path.expanduser("~/dangerous-pi/scripts/generate-ssl-cert.sh"),
os.path.join(os.path.dirname(__file__), "../../../scripts/generate-ssl-cert.sh"),
]
script_path = None
for candidate in script_candidates:
if os.path.exists(candidate):
script_path = candidate
break
# Check if script exists
if not os.path.exists(script_path):
if not script_path:
raise HTTPException(
status_code=500,
detail="SSL certificate generation script not found"
@@ -975,4 +988,217 @@ async def clear_dismissed_widgets():
plugin_manager = get_plugin_manager()
plugin_manager.clear_dismissed()
# ---------------------------------------------------------------------------
# OS Updates API
# ---------------------------------------------------------------------------
@router.get("/os/info")
async def get_os_info():
"""Get OS-level system information.
Returns Debian version, kernel, architecture, uptime, hostname,
last apt update timestamp, auto-update setting, and reboot-required status.
"""
manager = get_os_update_manager()
return await manager.get_os_info()
@router.get("/os/updates")
async def get_os_updates(refresh: bool = False):
"""Get available OS package updates.
Returns a list of upgradable packages with current and available versions.
Results are cached for 1 hour unless refresh=True.
"""
manager = get_os_update_manager()
packages = await manager.check_available_updates(force_refresh=refresh)
return {
"count": len(packages),
"packages": packages,
"upgrading": manager.is_upgrading,
}
class OsUpgradeRequest(BaseModel):
"""Request to trigger an OS package upgrade."""
security_only: bool = False
@router.post("/os/update")
async def run_os_update(request: OsUpgradeRequest):
"""Trigger an OS package upgrade.
Runs `apt-get upgrade -y` (or unattended-upgrade --verbose for security-only).
Only one upgrade can run at a time.
Args:
security_only: If True, only install security updates
"""
manager = get_os_update_manager()
result = await manager.run_upgrade(security_only=request.security_only)
if not result.get("success"):
raise HTTPException(status_code=409 if "already in progress" in result.get("error", "") else 500,
detail=result.get("error", "Upgrade failed"))
return result
class AutoUpdatesRequest(BaseModel):
"""Request to toggle automatic security updates."""
enabled: bool
@router.post("/os/auto-updates")
async def toggle_auto_updates(request: AutoUpdatesRequest):
"""Toggle automatic security updates.
Writes AUTO_SECURITY_UPDATES to the .env file and restarts the
systemd timer that controls unattended-upgrades.
Args:
enabled: True to enable, False to disable automatic security updates
"""
manager = get_os_update_manager()
result = await manager.toggle_auto_updates(request.enabled)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to toggle auto-updates"))
return result
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
class ThemeDefinitionResponse(BaseModel):
"""A single theme definition."""
id: str
name: str
description: str = ""
supportsModes: List[str] = ["dark", "light", "auto"]
defaultMode: str = "dark"
author: str = ""
css_url: str = ""
source: str = "builtin" # "builtin" or "plugin"
class ThemeRegistryResponse(BaseModel):
"""Response for GET /api/system/themes."""
themes: List[ThemeDefinitionResponse]
def _scan_theme_dirs() -> List[ThemeDefinitionResponse]:
"""Scan themes directories for installed theme packages."""
themes_found: List[ThemeDefinitionResponse] = []
# Search paths: dev + production
themes_dirs = [
Path(__file__).parent.parent.parent / "frontend" / "themes", # dev
Path("/opt/dangerous-pi/app/frontend/themes"), # prod
]
seen_ids: set = set()
for themes_dir in themes_dirs:
if not themes_dir.is_dir():
continue
for entry in sorted(themes_dir.iterdir()):
if not entry.is_dir() or entry.name.startswith("."):
continue
theme_json = entry / "theme.json"
if not theme_json.exists():
continue
try:
meta = json.loads(theme_json.read_text())
theme_id = meta.get("id", entry.name)
if theme_id in seen_ids:
continue
seen_ids.add(theme_id)
themes_found.append(ThemeDefinitionResponse(
id=theme_id,
name=meta.get("name", theme_id),
description=meta.get("description", ""),
supportsModes=meta.get("supportsModes", ["dark", "light", "auto"]),
defaultMode=meta.get("defaultMode", "dark"),
author=meta.get("author", ""),
css_url=f"/themes/{theme_id}/tokens.css",
source="builtin",
))
except (json.JSONDecodeError, OSError) as exc:
print(f"Warning: bad theme.json in {entry}: {exc}")
return themes_found
def _scan_plugin_themes() -> List[ThemeDefinitionResponse]:
"""Collect themes registered by plugins via the theme_register hook."""
from ..managers.plugin_manager import get_plugin_manager
pm = get_plugin_manager()
themes: List[ThemeDefinitionResponse] = []
if "theme_register" not in pm._hooks:
return themes
import asyncio
results = []
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
# Hooks are called synchronously here since they are simple data returns
for callback in pm._hooks.get("theme_register", []):
try:
if asyncio.iscoroutinefunction(callback):
# Schedule in running loop if available
if loop:
import concurrent.futures
# Can't await in sync context; skip async hooks
continue
else:
continue
result = callback()
if result:
results.append(result)
except Exception as exc:
print(f"Warning: theme_register hook error: {exc}")
for r in results:
themes.append(ThemeDefinitionResponse(
id=r.get("id", "unknown"),
name=r.get("name", "Unknown Theme"),
description=r.get("description", ""),
supportsModes=r.get("supportsModes", ["dark", "light", "auto"]),
defaultMode=r.get("defaultMode", "dark"),
author=r.get("author", ""),
css_url=r.get("css_url", f"/themes/{r.get('id', 'unknown')}/tokens.css"),
source="plugin",
))
return themes
@router.get("/themes", response_model=ThemeRegistryResponse)
async def get_available_themes():
"""Get available themes from disk and plugin registry.
Scans the themes/ directory for installed theme packages
and collects themes registered by plugins via the theme_register hook.
"""
builtin = _scan_theme_dirs()
plugin_themes = _scan_plugin_themes()
# Merge, preferring builtin for duplicate IDs
seen = {t.id for t in builtin}
all_themes = list(builtin)
for pt in plugin_themes:
if pt.id not in seen:
all_themes.append(pt)
seen.add(pt.id)
return ThemeRegistryResponse(themes=all_themes)
return {"success": True, "message": "Dismissed widgets cleared"}