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:
@@ -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"}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
Refactored to use UpdateService for all business logic.
|
||||
Endpoints are now thin adapters that convert HTTP requests/responses.
|
||||
|
||||
Supports both legacy whole-system operations and per-component operations.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.container import container
|
||||
@@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ComponentUpdateInfo(BaseModel):
|
||||
"""Per-component update information."""
|
||||
component_id: str
|
||||
current_version: Optional[str] = None
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
compatible: bool = True
|
||||
incompatible_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PluginUpdateInfo(BaseModel):
|
||||
"""Per-plugin update information."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
"""Response model for update check."""
|
||||
update_available: bool
|
||||
@@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel):
|
||||
is_prerelease: bool = False
|
||||
download_size: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
components: List[ComponentUpdateInfo] = []
|
||||
plugins: List[PluginUpdateInfo] = []
|
||||
|
||||
|
||||
class UpdateProgressResponse(BaseModel):
|
||||
@@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel):
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
last_check: Optional[str] = None
|
||||
active_component: Optional[str] = None
|
||||
components: List[dict] = []
|
||||
|
||||
|
||||
class ReleaseNotesRequest(BaseModel):
|
||||
@@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel):
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
class ComponentDownloadRequest(BaseModel):
|
||||
"""Optional request body for selective download."""
|
||||
components: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _service_error_to_http_status(error_code: str) -> int:
|
||||
"""Map service error codes to HTTP status codes.
|
||||
|
||||
Args:
|
||||
error_code: Service error code
|
||||
|
||||
Returns:
|
||||
HTTP status code
|
||||
"""
|
||||
"""Map service error codes to HTTP status codes."""
|
||||
codes = {
|
||||
"update_check_error": 500,
|
||||
"no_update_available": 400,
|
||||
@@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int:
|
||||
"release_notes_error": 500,
|
||||
"check_download_error": 500,
|
||||
"full_update_error": 500,
|
||||
"manifest_error": 500,
|
||||
"component_not_available": 400,
|
||||
"component_download_error": 500,
|
||||
"component_install_error": 500,
|
||||
"rollback_error": 500,
|
||||
}
|
||||
return codes.get(error_code, 500)
|
||||
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.check_for_updates()
|
||||
|
||||
def _raise_on_error(result):
|
||||
"""Raise HTTPException if result indicates failure."""
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
detail=result.error.message,
|
||||
)
|
||||
|
||||
# Send BLE notification if update is available
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy endpoints (backward-compatible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates (components + plugins)."""
|
||||
result = await container.update_service.check_for_updates()
|
||||
_raise_on_error(result)
|
||||
|
||||
# BLE notification
|
||||
if result.data.get("update_available"):
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_AVAILABLE,
|
||||
f"Update available: v{result.data['latest_version']}",
|
||||
{"version": result.data["latest_version"]}
|
||||
{"version": result.data["latest_version"]},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return UpdateCheckResponse(**result.data)
|
||||
@@ -98,108 +140,126 @@ async def check_for_updates():
|
||||
|
||||
@router.get("/progress", response_model=UpdateProgressResponse)
|
||||
async def get_update_progress():
|
||||
"""Get current update progress.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current update progress."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return UpdateProgressResponse(**result.data)
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_update():
|
||||
"""Download the available update.
|
||||
async def download_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Download available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, downloads all available components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, downloads only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.download_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components downloaded", "components": results}
|
||||
|
||||
result = await container.update_service.download_update()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {"message": result.data["message"]}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_update():
|
||||
"""Install the downloaded update.
|
||||
async def install_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Install downloaded updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, installs all downloaded components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, installs only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.install_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components installed", "components": results}
|
||||
|
||||
result = await container.update_service.install_update()
|
||||
_raise_on_error(result)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
# Send BLE notification
|
||||
# BLE notification
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_COMPLETE,
|
||||
"Update installed successfully",
|
||||
{"restart_required": True}
|
||||
{"restart_required": True},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": result.data["message"],
|
||||
"restart_required": result.data.get("requires_restart", True)
|
||||
"restart_required": result.data.get("requires_restart", True),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/release-notes", response_model=dict)
|
||||
async def get_release_notes(request: ReleaseNotesRequest):
|
||||
"""Get release notes for a specific version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
|
||||
Args:
|
||||
request: Version to get notes for (latest if not specified)
|
||||
"""
|
||||
"""Get release notes for a specific version."""
|
||||
result = await container.update_service.get_release_notes(request.version)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["version"],
|
||||
"notes": result.data["release_notes"]
|
||||
"notes": result.data["release_notes"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current-version")
|
||||
async def get_current_version():
|
||||
"""Get current system version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current system version."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["current_version"],
|
||||
"last_check": result.data["last_check"]
|
||||
"last_check": result.data["last_check"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component-level endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/components")
|
||||
async def get_installed_components():
|
||||
"""Get installed component manifest."""
|
||||
result = await container.update_service.get_installed_components()
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/download")
|
||||
async def download_component(
|
||||
component_id: str = Path(description="Component ID (pm3, frontend, backend, theme)"),
|
||||
):
|
||||
"""Download a single component update."""
|
||||
result = await container.update_service.download_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/install")
|
||||
async def install_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Install a single downloaded component."""
|
||||
result = await container.update_service.install_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/rollback")
|
||||
async def rollback_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Rollback a component to its backup."""
|
||||
result = await container.update_service.rollback_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
@@ -30,6 +30,9 @@ VERSION = os.getenv("VERSION", "0.1.0")
|
||||
# Update settings
|
||||
GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi")
|
||||
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour
|
||||
COMPONENT_MANIFEST_PATH = os.getenv(
|
||||
"COMPONENT_MANIFEST_PATH", "/opt/dangerous-pi/component-manifest.json"
|
||||
)
|
||||
|
||||
# Wi-Fi settings
|
||||
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")
|
||||
|
||||
@@ -204,6 +204,17 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], depe
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
|
||||
|
||||
# Serve theme token CSS from themes/ directory
|
||||
theme_paths = [
|
||||
Path(__file__).parent.parent / "frontend" / "themes", # Development
|
||||
Path("/opt/dangerous-pi/app/frontend/themes"), # Production
|
||||
]
|
||||
for tp in theme_paths:
|
||||
if tp.exists() and tp.is_dir():
|
||||
app.mount("/themes", StaticFiles(directory=str(tp)), name="themes")
|
||||
print(f"🎨 Serving themes from: {tp}")
|
||||
break
|
||||
|
||||
# Serve frontend static files if build directory exists
|
||||
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build
|
||||
# Priority: 1) check relative to working directory, 2) check absolute production path
|
||||
|
||||
@@ -210,7 +210,7 @@ class BLEManager:
|
||||
}
|
||||
|
||||
async def _check_bluetooth_adapter(self) -> bool:
|
||||
"""Check if Bluetooth adapter is available.
|
||||
"""Check if Bluetooth adapter is available, unblocking and powering on if needed.
|
||||
|
||||
Returns:
|
||||
True if adapter is available, False otherwise
|
||||
@@ -224,8 +224,20 @@ class BLEManager:
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# If we get output with "Controller", we have an adapter
|
||||
return b"Controller" in stdout
|
||||
if b"Controller" not in stdout:
|
||||
return False
|
||||
|
||||
# Unblock Bluetooth if soft-blocked by rfkill
|
||||
await self._run_cmd("rfkill", "unblock", "bluetooth")
|
||||
|
||||
# Power on the adapter via bluetoothctl
|
||||
out = await self._run_cmd("bluetoothctl", "power", "on")
|
||||
if b"succeeded" in out.lower() or b"yes" in out.lower():
|
||||
logger.info("Bluetooth adapter powered on")
|
||||
else:
|
||||
logger.warning("Bluetooth power on response: %s", out.decode(errors='replace').strip())
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("bluetoothctl not found - BlueZ not installed")
|
||||
@@ -234,6 +246,19 @@ class BLEManager:
|
||||
logger.error("Error checking Bluetooth adapter: %s", e)
|
||||
return False
|
||||
|
||||
async def _run_cmd(self, *args: str) -> bytes:
|
||||
"""Run a command and return stdout."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
return stdout
|
||||
except FileNotFoundError:
|
||||
return b""
|
||||
|
||||
async def _set_device_name(self, name: str):
|
||||
"""Set the Bluetooth device name.
|
||||
|
||||
|
||||
312
app/backend/managers/os_update_manager.py
Normal file
312
app/backend/managers/os_update_manager.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""OS Update Manager for Dangerous Pi.
|
||||
|
||||
Provides visibility into OS-level package updates and allows triggering
|
||||
apt upgrades from the web UI. Works alongside unattended-upgrades which
|
||||
handles automatic security patches.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsPackageUpdate:
|
||||
"""A single upgradable OS package."""
|
||||
name: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
architecture: str = ""
|
||||
origin: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsInfo:
|
||||
"""OS-level system information."""
|
||||
debian_version: str = ""
|
||||
debian_codename: str = ""
|
||||
kernel: str = ""
|
||||
architecture: str = ""
|
||||
uptime_seconds: float = 0
|
||||
hostname: str = ""
|
||||
last_apt_update: Optional[str] = None
|
||||
auto_security_updates: bool = True
|
||||
reboot_required: bool = False
|
||||
|
||||
|
||||
class OsUpdateManager:
|
||||
"""Manages OS-level package updates."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: List[OsPackageUpdate] = []
|
||||
self._cache_time: float = 0
|
||||
self._cache_ttl: float = 3600 # 1 hour
|
||||
self._upgrading: bool = False
|
||||
self._upgrade_output: List[str] = []
|
||||
self._env_path = Path(os.getenv("ENV_FILE", "/opt/dangerous-pi/.env"))
|
||||
|
||||
async def get_os_info(self) -> Dict[str, Any]:
|
||||
"""Get OS-level system information."""
|
||||
info = OsInfo()
|
||||
|
||||
# Debian version
|
||||
try:
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
content = os_release.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("VERSION_ID="):
|
||||
info.debian_version = line.split("=", 1)[1].strip('"')
|
||||
elif line.startswith("VERSION_CODENAME="):
|
||||
info.debian_codename = line.split("=", 1)[1].strip('"')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Kernel
|
||||
info.kernel = platform.release()
|
||||
|
||||
# Architecture
|
||||
info.architecture = platform.machine()
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
uptime_path = Path("/proc/uptime")
|
||||
if uptime_path.exists():
|
||||
info.uptime_seconds = float(uptime_path.read_text().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hostname
|
||||
info.hostname = platform.node()
|
||||
|
||||
# Last apt update (mtime of apt lists directory)
|
||||
try:
|
||||
apt_lists = Path("/var/lib/apt/lists")
|
||||
if apt_lists.exists():
|
||||
mtime = apt_lists.stat().st_mtime
|
||||
from datetime import datetime, timezone
|
||||
info.last_apt_update = datetime.fromtimestamp(
|
||||
mtime, tz=timezone.utc
|
||||
).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto security updates setting
|
||||
info.auto_security_updates = self._read_auto_updates_setting()
|
||||
|
||||
# Reboot required
|
||||
info.reboot_required = Path("/var/run/reboot-required").exists()
|
||||
|
||||
return {
|
||||
"debian_version": info.debian_version,
|
||||
"debian_codename": info.debian_codename,
|
||||
"kernel": info.kernel,
|
||||
"architecture": info.architecture,
|
||||
"uptime_seconds": info.uptime_seconds,
|
||||
"hostname": info.hostname,
|
||||
"last_apt_update": info.last_apt_update,
|
||||
"auto_security_updates": info.auto_security_updates,
|
||||
"reboot_required": info.reboot_required,
|
||||
}
|
||||
|
||||
async def check_available_updates(self, force_refresh: bool = False) -> List[Dict[str, str]]:
|
||||
"""Check for available OS package updates.
|
||||
|
||||
Returns cached results unless force_refresh=True or cache has expired.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force_refresh and self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"apt", "list", "--upgradable", "-qq",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
output = stdout.decode(errors="replace").strip()
|
||||
|
||||
packages = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Listing"):
|
||||
continue
|
||||
pkg = self._parse_apt_line(line)
|
||||
if pkg:
|
||||
packages.append(pkg)
|
||||
|
||||
self._cache = packages
|
||||
self._cache_time = now
|
||||
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
# Return stale cache on error rather than failing
|
||||
if not self._cache:
|
||||
return [{"name": "error", "current_version": "", "available_version": str(e), "architecture": "", "origin": ""}]
|
||||
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
async def run_upgrade(self, security_only: bool = False) -> Dict[str, Any]:
|
||||
"""Trigger an apt upgrade.
|
||||
|
||||
Returns the result after completion. Only one upgrade can run at a time.
|
||||
Uses create_subprocess_exec (not shell) to avoid injection risks.
|
||||
"""
|
||||
if self._upgrading:
|
||||
return {"success": False, "error": "An upgrade is already in progress"}
|
||||
|
||||
self._upgrading = True
|
||||
self._upgrade_output = []
|
||||
|
||||
try:
|
||||
if security_only:
|
||||
cmd = ["sudo", "unattended-upgrade", "--verbose"]
|
||||
else:
|
||||
cmd = ["sudo", "apt-get", "upgrade", "-y"]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
||||
)
|
||||
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode(errors="replace").rstrip()
|
||||
self._upgrade_output.append(decoded)
|
||||
|
||||
await proc.wait()
|
||||
|
||||
# Invalidate cache after upgrade
|
||||
self._cache = []
|
||||
self._cache_time = 0
|
||||
|
||||
success = proc.returncode == 0
|
||||
return {
|
||||
"success": success,
|
||||
"return_code": proc.returncode,
|
||||
"output": self._upgrade_output,
|
||||
"reboot_required": Path("/var/run/reboot-required").exists(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "output": self._upgrade_output}
|
||||
|
||||
finally:
|
||||
self._upgrading = False
|
||||
|
||||
async def toggle_auto_updates(self, enabled: bool) -> Dict[str, Any]:
|
||||
"""Toggle automatic security updates by writing to .env and restarting the timer."""
|
||||
try:
|
||||
self._write_auto_updates_setting(enabled)
|
||||
|
||||
# Restart the toggle service to apply
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "dangerous-pi-auto-updates.service",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"auto_security_updates": enabled,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@property
|
||||
def is_upgrading(self) -> bool:
|
||||
return self._upgrading
|
||||
|
||||
@property
|
||||
def upgrade_output(self) -> List[str]:
|
||||
return list(self._upgrade_output)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _parse_apt_line(self, line: str) -> Optional[OsPackageUpdate]:
|
||||
"""Parse a line from `apt list --upgradable -qq`.
|
||||
|
||||
Format: package/origin version arch [upgradable from: old_version]
|
||||
"""
|
||||
match = re.match(
|
||||
r"^(\S+?)(?:/(\S+))?\s+(\S+)\s+(\S+)\s+\[upgradable from:\s+(\S+)\]",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
return OsPackageUpdate(
|
||||
name=match.group(1),
|
||||
origin=match.group(2) or "",
|
||||
available_version=match.group(3),
|
||||
architecture=match.group(4),
|
||||
current_version=match.group(5),
|
||||
)
|
||||
return None
|
||||
|
||||
def _package_to_dict(self, pkg: OsPackageUpdate) -> Dict[str, str]:
|
||||
return {
|
||||
"name": pkg.name,
|
||||
"current_version": pkg.current_version,
|
||||
"available_version": pkg.available_version,
|
||||
"architecture": pkg.architecture,
|
||||
"origin": pkg.origin,
|
||||
}
|
||||
|
||||
def _read_auto_updates_setting(self) -> bool:
|
||||
"""Read AUTO_SECURITY_UPDATES from .env file."""
|
||||
try:
|
||||
if self._env_path.exists():
|
||||
for line in self._env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("AUTO_SECURITY_UPDATES="):
|
||||
val = line.split("=", 1)[1].strip().lower()
|
||||
return val != "false"
|
||||
except Exception:
|
||||
pass
|
||||
return True # Default: enabled
|
||||
|
||||
def _write_auto_updates_setting(self, enabled: bool):
|
||||
"""Write AUTO_SECURITY_UPDATES to .env file."""
|
||||
value = "true" if enabled else "false"
|
||||
env_line = f"AUTO_SECURITY_UPDATES={value}"
|
||||
|
||||
if not self._env_path.exists():
|
||||
self._env_path.write_text(env_line + "\n")
|
||||
return
|
||||
|
||||
lines = self._env_path.read_text().splitlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("AUTO_SECURITY_UPDATES="):
|
||||
lines[i] = env_line
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
lines.append(env_line)
|
||||
|
||||
self._env_path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
# Singleton
|
||||
_os_update_manager: Optional[OsUpdateManager] = None
|
||||
|
||||
|
||||
def get_os_update_manager() -> OsUpdateManager:
|
||||
"""Get or create the OS update manager singleton."""
|
||||
global _os_update_manager
|
||||
if _os_update_manager is None:
|
||||
_os_update_manager = OsUpdateManager()
|
||||
return _os_update_manager
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,6 +17,8 @@ from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable, Set
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@@ -400,6 +403,18 @@ class PluginBase:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginUpdateInfo:
|
||||
"""Information about an available plugin update."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_url: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
|
||||
|
||||
@@ -790,6 +805,121 @@ class PluginManager:
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Expired widget removed: {widget_id}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Plugin Update Checking
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_github_repo(homepage: Optional[str]) -> Optional[str]:
|
||||
"""Extract 'owner/repo' from a GitHub URL.
|
||||
|
||||
Args:
|
||||
homepage: Plugin homepage URL
|
||||
|
||||
Returns:
|
||||
'owner/repo' string or None
|
||||
"""
|
||||
if not homepage:
|
||||
return None
|
||||
m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", homepage)
|
||||
return m.group(1) if m else None
|
||||
|
||||
async def check_plugin_updates(self) -> List[PluginUpdateInfo]:
|
||||
"""Check all installed plugins for available updates.
|
||||
|
||||
Queries each plugin's GitHub repo (derived from homepage) for
|
||||
newer releases. Skips plugins without a GitHub homepage.
|
||||
|
||||
Returns:
|
||||
List of PluginUpdateInfo for plugins with updates available
|
||||
"""
|
||||
updates: List[PluginUpdateInfo] = []
|
||||
|
||||
plugins_to_check = []
|
||||
for plugin_id, info in self._plugins.items():
|
||||
repo = self._extract_github_repo(info.metadata.homepage)
|
||||
if repo:
|
||||
plugins_to_check.append((plugin_id, info, repo))
|
||||
|
||||
if not plugins_to_check:
|
||||
return updates
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for plugin_id, info, repo in plugins_to_check:
|
||||
try:
|
||||
update = await self._check_single_plugin_update(
|
||||
session, plugin_id, info, repo
|
||||
)
|
||||
if update:
|
||||
updates.append(update)
|
||||
except Exception as e:
|
||||
print(f"Error checking updates for plugin {plugin_id}: {e}")
|
||||
|
||||
return updates
|
||||
|
||||
async def _check_single_plugin_update(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
plugin_id: str,
|
||||
info: PluginInfo,
|
||||
repo: str,
|
||||
) -> Optional[PluginUpdateInfo]:
|
||||
"""Check a single plugin for available update.
|
||||
|
||||
Args:
|
||||
session: aiohttp session
|
||||
plugin_id: Plugin identifier
|
||||
info: Current plugin info
|
||||
repo: GitHub 'owner/repo' string
|
||||
|
||||
Returns:
|
||||
PluginUpdateInfo if update available, None otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = await resp.json()
|
||||
|
||||
latest_tag = data.get("tag_name", "").lstrip("v")
|
||||
current_ver = info.metadata.version
|
||||
|
||||
if not self._is_newer_plugin_version(latest_tag, current_ver):
|
||||
return None
|
||||
|
||||
# Find a suitable download asset
|
||||
download_url = ""
|
||||
download_size = None
|
||||
for asset in data.get("assets", []):
|
||||
if asset["name"].endswith((".tar.gz", ".zip")):
|
||||
download_url = asset["browser_download_url"]
|
||||
download_size = asset.get("size")
|
||||
break
|
||||
|
||||
# Fall back to source tarball
|
||||
if not download_url:
|
||||
download_url = data.get("tarball_url", "")
|
||||
|
||||
return PluginUpdateInfo(
|
||||
plugin_id=plugin_id,
|
||||
current_version=current_ver,
|
||||
available_version=latest_tag,
|
||||
changelog=data.get("body", ""),
|
||||
download_url=download_url,
|
||||
download_size=download_size,
|
||||
source_repo=repo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_newer_plugin_version(latest: str, current: str) -> bool:
|
||||
"""Compare two version strings, returning True if latest > current."""
|
||||
def parse(v: str) -> tuple:
|
||||
return tuple(int(x) for x in re.split(r'[-+]', v)[0].split('.'))
|
||||
try:
|
||||
return parse(latest) > parse(current)
|
||||
except (ValueError, AttributeError):
|
||||
return latest != current
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,12 @@
|
||||
This service provides software update operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import asdict
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
|
||||
from ..managers.update_manager import (
|
||||
UpdateManager, UpdateProgress, UpdateStatus, ComponentId,
|
||||
)
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
@@ -156,6 +159,15 @@ class UpdateService:
|
||||
try:
|
||||
progress = await self.update_manager.get_progress()
|
||||
|
||||
comp_list = []
|
||||
for comp_id, cp in progress.component_progress.items():
|
||||
comp_list.append({
|
||||
"component_id": cp.component_id,
|
||||
"status": cp.status.value,
|
||||
"download_progress": cp.download_progress,
|
||||
"error_message": cp.error_message,
|
||||
})
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
@@ -164,7 +176,9 @@ class UpdateService:
|
||||
"available_version": progress.available_version,
|
||||
"download_progress": progress.download_progress,
|
||||
"error_message": progress.error_message,
|
||||
"last_check": progress.last_check
|
||||
"last_check": progress.last_check,
|
||||
"active_component": progress.active_component,
|
||||
"components": comp_list,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -310,3 +324,151 @@ class UpdateService:
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Component-level operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_installed_components(self) -> PM3ServiceResult:
|
||||
"""Get the installed component manifest.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with component manifest data
|
||||
"""
|
||||
try:
|
||||
manifest = self.update_manager.get_manifest()
|
||||
components = {}
|
||||
for comp_id, comp in manifest.components.items():
|
||||
components[comp_id] = asdict(comp)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"schema_version": manifest.schema_version,
|
||||
"system_version": manifest.system_version,
|
||||
"components": components,
|
||||
"plugins": manifest.plugins,
|
||||
"last_updated": manifest.last_updated,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="manifest_error",
|
||||
message="Failed to get component manifest",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def download_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Download a specific component update.
|
||||
|
||||
Args:
|
||||
component_id: Component to download (pm3, frontend, backend, theme)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating download success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.download_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} downloaded successfully",
|
||||
"component_id": component_id,
|
||||
"ready_to_install": True,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_not_available",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_download_error",
|
||||
message=f"Error downloading {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def install_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Install a downloaded component update.
|
||||
|
||||
Args:
|
||||
component_id: Component to install
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating install success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.install_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} installed successfully",
|
||||
"component_id": component_id,
|
||||
"requires_restart": component_id in (
|
||||
ComponentId.BACKEND, ComponentId.PM3
|
||||
),
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_install_error",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_install_error",
|
||||
message=f"Error installing {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def rollback_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Rollback a component to its previous version.
|
||||
|
||||
Args:
|
||||
component_id: Component to rollback
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating rollback success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.rollback_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} rolled back successfully",
|
||||
"component_id": component_id,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="rollback_error",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="rollback_error",
|
||||
message=f"Error rolling back {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ConnectDialog({ network, onClose, isSubmitting }: Connec
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.9)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.9)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function DeviceSelector({
|
||||
style={{
|
||||
marginBottom: "var(--space-3)",
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
@@ -318,7 +318,7 @@ export default function DeviceSelector({
|
||||
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
|
||||
opacity: isSelectable || isFlashing ? 1 : 0.6,
|
||||
transition: "all 150ms ease",
|
||||
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
|
||||
background: isSelected ? "rgba(var(--color-primary-rgb), 0.05)" : "transparent",
|
||||
position: "relative",
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -336,7 +336,7 @@ export default function DeviceSelector({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.8)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.8)",
|
||||
borderRadius: "var(--radius)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -442,7 +442,7 @@ export default function DeviceSelector({
|
||||
style={{
|
||||
marginTop: "var(--space-2)",
|
||||
padding: "var(--space-2)",
|
||||
background: "rgba(217, 119, 6, 0.1)",
|
||||
background: "rgba(var(--color-warning-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-warning)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: "0.75rem",
|
||||
@@ -538,7 +538,7 @@ export default function DeviceSelector({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.75)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
@@ -600,7 +600,7 @@ export default function DeviceSelector({
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "var(--space-3)",
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LoginPrompt() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.75)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.95)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.95)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -203,7 +203,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(255, 107, 107, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
border: "1px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
marginBottom: "var(--space-4)",
|
||||
|
||||
155
app/frontend/app/lib/ThemeContext.tsx
Normal file
155
app/frontend/app/lib/ThemeContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
|
||||
/* Theme definitions — mirrors @dangerousthings/tokens types */
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
supportsModes: ThemeMode[];
|
||||
defaultMode: ThemeMode;
|
||||
author?: string;
|
||||
css_url?: string;
|
||||
source?: "builtin" | "plugin";
|
||||
}
|
||||
|
||||
export type ThemeBrand = string; // dynamic — supports plugin themes beyond dt/classic/supra
|
||||
export type ThemeMode = "dark" | "light" | "auto";
|
||||
|
||||
/** Hardcoded fallback themes for SSR / offline / initial render */
|
||||
export const builtinThemes: ThemeDefinition[] = [
|
||||
{
|
||||
id: "dt",
|
||||
name: "Dangerous Things",
|
||||
description: "Official DT brand \u2014 Tektur font, neon cyberpunk, beveled corners",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
{
|
||||
id: "classic",
|
||||
name: "Classic Cyberpunk",
|
||||
description: "Original dark navy aesthetic with magenta accents",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
{
|
||||
id: "supra",
|
||||
name: "VivoKey Supra",
|
||||
description: "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
];
|
||||
|
||||
/** @deprecated Use builtinThemes instead */
|
||||
export const themes = builtinThemes;
|
||||
|
||||
export const DEFAULT_THEME: ThemeBrand = "dt";
|
||||
|
||||
const BRAND_STORAGE_KEY = "dpi_theme_brand";
|
||||
const MODE_STORAGE_KEY = "theme"; // backward compatible with existing key
|
||||
|
||||
interface ThemeContextValue {
|
||||
brand: ThemeBrand;
|
||||
mode: ThemeMode;
|
||||
setBrand: (brand: ThemeBrand) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
themeDefinition: ThemeDefinition;
|
||||
availableThemes: ThemeDefinition[];
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Inject or update a <link> element for a theme's dynamic token CSS.
|
||||
* Identified by a fixed id so it can be swapped in place.
|
||||
*/
|
||||
function setTokenStylesheet(url: string | undefined) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const LINK_ID = "dp-theme-tokens";
|
||||
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||
|
||||
if (!url) {
|
||||
link?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (link.getAttribute("href") !== url) {
|
||||
link.setAttribute("href", url);
|
||||
}
|
||||
} else {
|
||||
link = document.createElement("link");
|
||||
link.id = LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [brand, setBrandState] = useState<ThemeBrand>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
return localStorage.getItem(BRAND_STORAGE_KEY) || DEFAULT_THEME;
|
||||
});
|
||||
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return (localStorage.getItem(MODE_STORAGE_KEY) as ThemeMode) || "dark";
|
||||
});
|
||||
|
||||
const [availableThemes, setAvailableThemes] = useState<ThemeDefinition[]>(builtinThemes);
|
||||
|
||||
// Fetch dynamic theme list from API on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/system/themes");
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (!cancelled && Array.isArray(data.themes) && data.themes.length > 0) {
|
||||
setAvailableThemes(data.themes);
|
||||
}
|
||||
} catch {
|
||||
// API unavailable — keep fallback themes
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const setBrand = useCallback((newBrand: ThemeBrand) => {
|
||||
setBrandState(newBrand);
|
||||
localStorage.setItem(BRAND_STORAGE_KEY, newBrand);
|
||||
document.documentElement.setAttribute("data-brand", newBrand);
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((newMode: ThemeMode) => {
|
||||
setModeState(newMode);
|
||||
localStorage.setItem(MODE_STORAGE_KEY, newMode);
|
||||
document.documentElement.setAttribute("data-theme", newMode);
|
||||
}, []);
|
||||
|
||||
// Set DOM attributes + inject token stylesheet when brand/mode/themes change
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-brand", brand);
|
||||
document.documentElement.setAttribute("data-theme", mode);
|
||||
|
||||
const def = availableThemes.find((t) => t.id === brand);
|
||||
setTokenStylesheet(def?.css_url);
|
||||
}, [brand, mode, availableThemes]);
|
||||
|
||||
const themeDefinition = availableThemes.find((t) => t.id === brand) || availableThemes[0] || builtinThemes[0];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ brand, mode, setBrand, setMode, themeDefinition, availableThemes }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -7,27 +7,40 @@ import {
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
} from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./styles.css?url";
|
||||
import baseStyles from "./styles/base.css?url";
|
||||
import themeStyles from "@dangerousthings/web/dist/index.css?url";
|
||||
import { PowerWidget } from "./components/PowerWidget";
|
||||
import { HeaderWidgets } from "./components/HeaderWidgets";
|
||||
import { LoginPrompt } from "./components/LoginPrompt";
|
||||
import { useWebSocketState } from "./hooks/useWebSocket";
|
||||
import { ThemeProvider, useTheme } from "./lib/ThemeContext";
|
||||
import type { ThemeMode } from "./lib/ThemeContext";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
{ rel: "stylesheet", href: baseStyles },
|
||||
{ rel: "stylesheet", href: themeStyles },
|
||||
{ rel: "preload", href: "/fonts/Tektur-VariableFont_wdth,wght.ttf", as: "font", type: "font/ttf", crossOrigin: "anonymous" },
|
||||
];
|
||||
|
||||
/**
|
||||
* FOUC prevention: static inline script reads localStorage and sets
|
||||
* data-brand/data-theme before first paint. This is a hardcoded string
|
||||
* constant (no user input), so it is safe from XSS.
|
||||
*/
|
||||
const FOUC_PREVENTION_SCRIPT = `(function(){try{var b=localStorage.getItem('dpi_theme_brand')||'dt';var m=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-brand',b);document.documentElement.setAttribute('data-theme',m);}catch(e){}})();`;
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" data-brand="dt" data-theme="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||
<meta name="theme-color" content="#0a0e1a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
|
||||
<Meta />
|
||||
<Links />
|
||||
{/* Prevent FOUC: hardcoded constant string, no user input — safe from XSS */}
|
||||
<script dangerouslySetInnerHTML={{ __html: FOUC_PREVENTION_SCRIPT }} />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
@@ -39,40 +52,30 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppInner />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const location = useLocation();
|
||||
const wsState = useWebSocketState();
|
||||
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
|
||||
const { mode, setMode } = useTheme();
|
||||
|
||||
// Initialize theme (default to dark, honor system preference if available)
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null;
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
} else {
|
||||
// Default to dark for Dangerous Things cyberpunk aesthetic
|
||||
setTheme("dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const themes: Array<"auto" | "dark" | "light"> = ["dark", "auto", "light"];
|
||||
const currentIndex = themes.indexOf(theme);
|
||||
const nextTheme = themes[(currentIndex + 1) % themes.length];
|
||||
|
||||
setTheme(nextTheme);
|
||||
localStorage.setItem("theme", nextTheme);
|
||||
document.documentElement.setAttribute("data-theme", nextTheme);
|
||||
const toggleMode = () => {
|
||||
const modes: ThemeMode[] = ["dark", "auto", "light"];
|
||||
const currentIndex = modes.indexOf(mode);
|
||||
setMode(modes[(currentIndex + 1) % modes.length]);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: "◈" },
|
||||
{ to: "/commands", label: "Commands", icon: "▶" },
|
||||
{ to: "/settings", label: "Settings", icon: "⚙" },
|
||||
{ to: "/updates", label: "Updates", icon: "🔄" },
|
||||
{ to: "/logs", label: "Logs", icon: "≡" },
|
||||
{ to: "/", label: "Dashboard", icon: "\u25C8" },
|
||||
{ to: "/commands", label: "Commands", icon: "\u25B6" },
|
||||
{ to: "/settings", label: "Settings", icon: "\u2699" },
|
||||
{ to: "/updates", label: "Updates", icon: "\uD83D\uDD04" },
|
||||
{ to: "/logs", label: "Logs", icon: "\u2261" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -87,13 +90,13 @@ export default function App() {
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<PowerWidget />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
onClick={toggleMode}
|
||||
className="btn-secondary"
|
||||
style={{ minWidth: "44px", padding: "0.5rem" }}
|
||||
aria-label="Toggle theme"
|
||||
title={`Current theme: ${theme}`}
|
||||
aria-label="Toggle theme mode"
|
||||
title={`Current mode: ${mode}`}
|
||||
>
|
||||
{theme === "dark" ? "◐" : theme === "light" ? "○" : "◑"}
|
||||
{mode === "dark" ? "\u25D0" : mode === "light" ? "\u25CB" : "\u25D1"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,7 +353,7 @@ export default function Dashboard() {
|
||||
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
|
||||
</p>
|
||||
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
|
||||
Built for <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
|
||||
Built by <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import ConnectDialog from "~/components/ConnectDialog";
|
||||
import { useTheme } from "~/lib/ThemeContext";
|
||||
import type { ThemeBrand, ThemeMode } from "~/lib/ThemeContext";
|
||||
|
||||
// Lazy load QR Scanner
|
||||
const QRScanner = lazy(() => import("~/components/QRScanner"));
|
||||
@@ -277,7 +279,8 @@ export default function Settings() {
|
||||
const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
|
||||
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system" | "appearance">("general");
|
||||
const { brand, mode, setBrand, setMode, availableThemes } = useTheme();
|
||||
const [scannedNetworks, setScannedNetworks] = useState<any[]>([]);
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
|
||||
const [showConnectDialog, setShowConnectDialog] = useState(false);
|
||||
@@ -336,6 +339,7 @@ export default function Settings() {
|
||||
{ id: "plugins", label: "Plugins", icon: "🔌" },
|
||||
{ id: "advanced", label: "Advanced", icon: "◈" },
|
||||
{ id: "system", label: "System", icon: "⚡" },
|
||||
{ id: "appearance", label: "Theme", icon: "◐" },
|
||||
];
|
||||
|
||||
const getSignalIcon = (strength: number) => {
|
||||
@@ -348,10 +352,6 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "var(--space-6)" }}>
|
||||
<span style={{ color: "var(--color-secondary)" }}>⚙</span> Settings
|
||||
</h1>
|
||||
|
||||
{actionData && "message" in actionData && (
|
||||
<div
|
||||
className="card"
|
||||
@@ -369,8 +369,11 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Tabs */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)", padding: "var(--space-2)" }}>
|
||||
{/* Settings Header + Section Tabs */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<h3 className="card-title">
|
||||
<span>⚙</span> Settings
|
||||
</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--space-2)" }}>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
@@ -775,7 +778,7 @@ export default function Settings() {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.95)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -965,6 +968,52 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appearance */}
|
||||
{activeSection === "appearance" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
||||
<div className="card">
|
||||
<h3 className="card-title">Theme</h3>
|
||||
<div className="form-group">
|
||||
<label className="label">Brand</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{availableThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setBrand(t.id as ThemeBrand)}
|
||||
className={`btn ${brand === t.id ? "btn-primary" : "btn-secondary"}`}
|
||||
style={{ flex: "1 1 auto", minWidth: "120px" }}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
|
||||
{availableThemes.find((t) => t.id === brand)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Mode</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
{(["dark", "auto", "light"] as ThemeMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`btn ${mode === m ? "btn-primary" : "btn-secondary"}`}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{m === "dark" ? "\u25D0 Dark" : m === "light" ? "\u25CB Light" : "\u25D1 Auto"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
|
||||
Auto follows your system preference
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Actions */}
|
||||
{activeSection === "system" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,10 @@
|
||||
/* Dangerous Pi - Cyberpunk Theme */
|
||||
/* Dangerous Pi - Base Styles (Theme-Agnostic) */
|
||||
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
|
||||
|
||||
/* ============================================================================
|
||||
Default token values (overridden by theme files via data-brand attribute)
|
||||
============================================================================ */
|
||||
:root {
|
||||
/* Cyberpunk Color Palette - Dark Mode Default */
|
||||
--color-bg: #0a0e1a;
|
||||
--color-surface: #121827;
|
||||
--color-surface-hover: #1a2332;
|
||||
--color-border: #1e293b;
|
||||
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
/* Neon Accents */
|
||||
--color-primary: #00ffff; /* Cyan */
|
||||
--color-primary-dim: #0891b2;
|
||||
--color-secondary: #ff00ff; /* Magenta */
|
||||
--color-accent: #00ff88; /* Green */
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #00ff88;
|
||||
--color-warning: #ffaa00;
|
||||
--color-error: #ff0055;
|
||||
--color-info: #00ffff;
|
||||
|
||||
/* Spacing (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
@@ -37,57 +18,25 @@
|
||||
--radius: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
/* Bevel sizes (DT theme sets real values; Classic/Supra set 0px) */
|
||||
--bevel-sm: 0px;
|
||||
--bevel-md: 0px;
|
||||
--bevel-lg: 0px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition: 250ms ease;
|
||||
|
||||
/* Monospace for terminal feel */
|
||||
/* Font stacks — themes override --font-heading and --font-body */
|
||||
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-body: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Light mode override (if user prefers) */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Force dark mode */
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Force light mode */
|
||||
:root[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
/* ============================================================================
|
||||
Reset & Base
|
||||
============================================================================ */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -95,7 +44,7 @@
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -109,17 +58,20 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
/* ============================================================================
|
||||
Typography
|
||||
============================================================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h1 { font-size: 1.875rem; } /* 30px */
|
||||
h2 { font-size: 1.5rem; } /* 24px */
|
||||
h3 { font-size: 1.25rem; } /* 20px */
|
||||
h4 { font-size: 1.125rem; } /* 18px */
|
||||
h1 { font-size: 1.875rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
@@ -135,7 +87,9 @@ a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
/* ============================================================================
|
||||
Layout
|
||||
============================================================================ */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
@@ -151,7 +105,7 @@ a:hover {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(18, 24, 39, 0.9);
|
||||
background: rgba(var(--color-surface-rgb), 0.9);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -166,6 +120,7 @@ a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary);
|
||||
@@ -180,7 +135,9 @@ a:hover {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
/* ============================================================================
|
||||
Navigation
|
||||
============================================================================ */
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -204,7 +161,7 @@ a:hover {
|
||||
.nav-link.active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
@@ -230,7 +187,7 @@ a:hover {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: 60px; /* Space for bottom nav */
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +198,9 @@ a:hover {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* ============================================================================
|
||||
Cards
|
||||
============================================================================ */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -255,6 +214,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
@@ -267,7 +227,9 @@ a:hover {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -275,6 +237,7 @@ a:hover {
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
@@ -282,21 +245,21 @@ a:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 44px; /* Touch-friendly */
|
||||
font-family: var(--font-sans);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
|
||||
box-shadow: 0 0 30px rgba(var(--color-accent-rgb), 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -307,19 +270,20 @@ a:hover {
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #cc0044;
|
||||
color: var(--color-bg);
|
||||
filter: brightness(0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -329,7 +293,9 @@ a:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
/* ============================================================================
|
||||
Status Badges
|
||||
============================================================================ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -343,25 +309,25 @@ a:hover {
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
background: rgba(var(--color-success-rgb), 0.2);
|
||||
color: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(255, 0, 85, 0.2);
|
||||
background: rgba(var(--color-error-rgb), 0.2);
|
||||
color: var(--color-error);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
background: rgba(var(--color-warning-rgb), 0.2);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
background: rgba(var(--color-info-rgb), 0.2);
|
||||
color: var(--color-info);
|
||||
border: 1px solid var(--color-info);
|
||||
}
|
||||
@@ -375,7 +341,9 @@ a:hover {
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
/* ============================================================================
|
||||
Forms
|
||||
============================================================================ */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@@ -406,14 +374,16 @@ a:hover {
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Terminal/Code Output */
|
||||
/* ============================================================================
|
||||
Terminal / Code Output
|
||||
============================================================================ */
|
||||
.terminal {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -446,7 +416,9 @@ a:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
/* ============================================================================
|
||||
Loading States
|
||||
============================================================================ */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -477,7 +449,9 @@ a:hover {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
/* ============================================================================
|
||||
Toast Notifications
|
||||
============================================================================ */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
@@ -502,7 +476,9 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
/* ============================================================================
|
||||
Utility Classes
|
||||
============================================================================ */
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
@@ -547,7 +523,9 @@ a:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
/* ============================================================================
|
||||
Responsive
|
||||
============================================================================ */
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
@@ -561,7 +539,9 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Power Widget */
|
||||
/* ============================================================================
|
||||
Power Widget
|
||||
============================================================================ */
|
||||
.power-widget {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -609,12 +589,8 @@ a:hover {
|
||||
}
|
||||
|
||||
@keyframes plug-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Battery Icon */
|
||||
@@ -657,7 +633,7 @@ a:hover {
|
||||
|
||||
.power-widget--charging {
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.power-widget--full {
|
||||
@@ -667,23 +643,23 @@ a:hover {
|
||||
|
||||
.power-widget--low {
|
||||
color: var(--color-warning);
|
||||
border-color: rgba(255, 170, 0, 0.3);
|
||||
border-color: rgba(var(--color-warning-rgb), 0.3);
|
||||
}
|
||||
|
||||
.power-widget--critical {
|
||||
color: var(--color-error);
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
border-color: rgba(var(--color-error-rgb), 0.3);
|
||||
animation: critical-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes critical-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
border-color: rgba(var(--color-error-rgb), 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
|
||||
box-shadow: 0 0 10px rgba(var(--color-error-rgb), 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,7 +677,6 @@ a:hover {
|
||||
/* ============================================================================
|
||||
Header Widgets - Notification banners below header
|
||||
============================================================================ */
|
||||
|
||||
.header-widgets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -733,25 +708,25 @@ a:hover {
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-info-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.widget-warning {
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
background: rgba(var(--color-warning-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.widget-error {
|
||||
background: rgba(255, 0, 85, 0.1);
|
||||
background: rgba(var(--color-error-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.widget-success {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
background: rgba(var(--color-success-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -769,7 +744,7 @@ a:hover {
|
||||
|
||||
.widget-action {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -780,7 +755,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.widget-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.widget-dismiss {
|
||||
21
app/frontend/package-lock.json
generated
21
app/frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "dangerous-pi-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
@@ -26,6 +27,22 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"../../../dt-design-system/packages/web": {
|
||||
"name": "@dangerousthings/web",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
}
|
||||
},
|
||||
"../../../dt-web-theme": {
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"../../dt-web-theme": {
|
||||
"extraneous": true
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -515,6 +532,10 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dangerousthings/web": {
|
||||
"resolved": "../../../dt-design-system/packages/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
BIN
app/frontend/public/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
BIN
app/frontend/public/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
8
app/frontend/themes/classic/theme.json
Normal file
8
app/frontend/themes/classic/theme.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "classic",
|
||||
"name": "Classic Cyberpunk",
|
||||
"description": "Original dark navy aesthetic with magenta accents",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things"
|
||||
}
|
||||
5
app/frontend/themes/classic/tokens.css
Normal file
5
app/frontend/themes/classic/tokens.css
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Classic Cyberpunk — Token CSS
|
||||
*
|
||||
* Generated by @dangerousthings/tokens during CI builds.
|
||||
*/
|
||||
8
app/frontend/themes/dt/theme.json
Normal file
8
app/frontend/themes/dt/theme.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "dt",
|
||||
"name": "Dangerous Things",
|
||||
"description": "Official DT brand — Tektur font, neon cyberpunk, beveled corners",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things"
|
||||
}
|
||||
11
app/frontend/themes/dt/tokens.css
Normal file
11
app/frontend/themes/dt/tokens.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Dangerous Things (dt) — Token CSS
|
||||
*
|
||||
* This file is generated by @dangerousthings/tokens during CI builds.
|
||||
* It contains CSS custom properties for brand colors, typography, and
|
||||
* shape tokens that override the structural base.css defaults.
|
||||
*
|
||||
* In development, the bundled @dangerousthings/web/dist/index.css provides
|
||||
* these tokens. This file is loaded dynamically in production when the
|
||||
* theme component is updated independently of the frontend.
|
||||
*/
|
||||
68
app/plugins/supra_theme/main.py
Normal file
68
app/plugins/supra_theme/main.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""VivoKey Supra Theme plugin for Dangerous Pi.
|
||||
|
||||
Registers the Supra theme (Material Design 3 with VivoKey blue, rounded
|
||||
corners, elevation shadows) via the theme_register hook so it appears in
|
||||
Settings > Appearance when this plugin is enabled.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_plugin_manager_module = (
|
||||
sys.modules.get('app.backend.managers.plugin_manager') or
|
||||
sys.modules.get('backend.managers.plugin_manager')
|
||||
)
|
||||
if _plugin_manager_module:
|
||||
PluginBase = _plugin_manager_module.PluginBase
|
||||
PluginMetadata = _plugin_manager_module.PluginMetadata
|
||||
else:
|
||||
from pathlib import Path as _P
|
||||
app_path = _P(__file__).parent.parent.parent
|
||||
if str(app_path) not in sys.path:
|
||||
sys.path.insert(0, str(app_path))
|
||||
from backend.managers.plugin_manager import PluginBase, PluginMetadata
|
||||
|
||||
# Paths
|
||||
_PLUGIN_DIR = Path(__file__).parent
|
||||
_STATIC_CSS = _PLUGIN_DIR / "static" / "tokens.css"
|
||||
|
||||
# Where the frontend serves themes from
|
||||
_THEMES_DIR = _PLUGIN_DIR.parent.parent / "frontend" / "themes" / "supra"
|
||||
|
||||
|
||||
class SupraThemePlugin(PluginBase):
|
||||
"""Provides the VivoKey Supra theme to Dangerous Pi."""
|
||||
|
||||
async def on_load(self):
|
||||
pass
|
||||
|
||||
async def on_enable(self):
|
||||
# Copy CSS to the themes directory so it's served by the /themes mount
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(_STATIC_CSS, _THEMES_DIR / "tokens.css")
|
||||
|
||||
# Register the theme via the theme_register hook.
|
||||
# MUST be synchronous — the theme scanner skips async callbacks.
|
||||
def supra_theme_register():
|
||||
return {
|
||||
"id": "supra",
|
||||
"name": "VivoKey Supra",
|
||||
"description": "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things",
|
||||
"css_url": "/themes/supra/tokens.css",
|
||||
}
|
||||
|
||||
self.register_hook("theme_register", supra_theme_register)
|
||||
|
||||
async def on_disable(self):
|
||||
# Clean up the copied theme files
|
||||
if _THEMES_DIR.exists():
|
||||
shutil.rmtree(_THEMES_DIR, ignore_errors=True)
|
||||
|
||||
async def on_unload(self):
|
||||
pass
|
||||
|
||||
def get_metadata(self) -> PluginMetadata:
|
||||
return self.metadata
|
||||
10
app/plugins/supra_theme/plugin.json
Normal file
10
app/plugins/supra_theme/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "supra_theme",
|
||||
"name": "VivoKey Supra Theme",
|
||||
"version": "1.0.0",
|
||||
"description": "Material Design 3 theme with VivoKey blue, rounded corners, and elevation shadows",
|
||||
"author": "Dangerous Things",
|
||||
"homepage": "https://github.com/dangerous-tac0s/dt-design-system",
|
||||
"dependencies": [],
|
||||
"permissions": []
|
||||
}
|
||||
232
app/plugins/supra_theme/static/tokens.css
Normal file
232
app/plugins/supra_theme/static/tokens.css
Normal file
@@ -0,0 +1,232 @@
|
||||
/* VivoKey Supra Theme — CSS Custom Properties + Component Overrides */
|
||||
/* Distributed as a Dangerous-Pi theme plugin */
|
||||
/* Source: @dangerousthings/tokens + @dangerousthings/web */
|
||||
|
||||
/* ============================================================================
|
||||
Dark Mode (Default)
|
||||
============================================================================ */
|
||||
:root[data-brand="supra"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #000000;
|
||||
--color-bg-rgb: 0, 0, 0;
|
||||
--color-surface: #28292E;
|
||||
--color-surface-rgb: 40, 41, 46;
|
||||
--color-surface-hover: #303136;
|
||||
--color-border: #42667E;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e2e8f0;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #8BAEC7;
|
||||
--color-secondary-rgb: 139, 174, 199;
|
||||
--color-accent: #8BAEC7;
|
||||
--color-accent-rgb: 139, 174, 199;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #8BB174;
|
||||
--color-success-rgb: 139, 177, 116;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: var(--font-sans);
|
||||
--font-body: var(--font-sans);
|
||||
|
||||
/* Shape */
|
||||
--bevel-sm: 0px;
|
||||
--bevel-md: 0px;
|
||||
--bevel-lg: 0px;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius: 0.625rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Light Mode
|
||||
============================================================================ */
|
||||
:root[data-brand="supra"][data-theme="light"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-rgb: 255, 255, 255;
|
||||
--color-surface: #f0f2f4;
|
||||
--color-surface-rgb: 240, 242, 244;
|
||||
--color-surface-hover: #e5e8eb;
|
||||
--color-border: #000000;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #F67448;
|
||||
--color-secondary-rgb: 246, 116, 72;
|
||||
--color-accent: #b45309;
|
||||
--color-accent-rgb: 180, 83, 9;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #4d7c0f;
|
||||
--color-success-rgb: 77, 124, 15;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Auto Mode (follows system preference)
|
||||
============================================================================ */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-brand="supra"][data-theme="auto"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-rgb: 255, 255, 255;
|
||||
--color-surface: #f0f2f4;
|
||||
--color-surface-rgb: 240, 242, 244;
|
||||
--color-surface-hover: #e5e8eb;
|
||||
--color-border: #000000;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #F67448;
|
||||
--color-secondary-rgb: 246, 116, 72;
|
||||
--color-accent: #b45309;
|
||||
--color-accent-rgb: 180, 83, 9;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #4d7c0f;
|
||||
--color-success-rgb: 77, 124, 15;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Supra Component Overrides — MD3 Elevation & Rounded Corners
|
||||
============================================================================ */
|
||||
|
||||
/* Card Elevation */
|
||||
[data-brand="supra"] .card {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
/* Button — Dual-layer inset shadow (hardware button feel) */
|
||||
[data-brand="supra"] .btn-primary {
|
||||
box-shadow:
|
||||
inset -2px -2px 4px rgba(0, 0, 0, 0.18),
|
||||
inset 2px 2px 4px rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-primary:hover {
|
||||
box-shadow:
|
||||
inset -2px -2px 6px rgba(0, 0, 0, 0.22),
|
||||
inset 2px 2px 6px rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-secondary {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-danger {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Badge — Rounded pill (MD3 chip style) */
|
||||
[data-brand="supra"] .badge {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Badge text — readable on translucent backgrounds */
|
||||
[data-brand="supra"] .badge-success,
|
||||
[data-brand="supra"] .badge-error,
|
||||
[data-brand="supra"] .badge-warning,
|
||||
[data-brand="supra"] .badge-info {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Input — Rounded with standard focus ring */
|
||||
[data-brand="supra"] .input,
|
||||
[data-brand="supra"] input,
|
||||
[data-brand="supra"] textarea,
|
||||
[data-brand="supra"] select {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .input:focus,
|
||||
[data-brand="supra"] input:focus,
|
||||
[data-brand="supra"] textarea:focus,
|
||||
[data-brand="supra"] select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Terminal — Rounded with subtle elevation */
|
||||
[data-brand="supra"] .terminal {
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Elevation Utility Classes */
|
||||
.supra-elevation-1 {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.supra-elevation-2 {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.supra-elevation-3 {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.supra-elevation-4 {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.supra-elevation-5 {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
Reference in New Issue
Block a user