feat: add auto-update system with release checking, notification UI, and install-type-aware apply
- Abstract ReleaseProvider interface (Gitea impl, swappable for GitHub/GitLab) - Background UpdateService with periodic checks, debounce, dismissed version persistence - Install type detection (installer/portable/docker/dev) with platform-aware asset matching - Download with progress events, silent NSIS reinstall, portable ZIP/tarball swap scripts - Version badge pulse animation, dismissible banner with icon buttons, Settings > Updates tab - Single source of truth: pyproject.toml version via importlib.metadata, CI stamps tag with sed - API: GET/POST status, check, dismiss, apply, GET/PUT settings - i18n: en, ru, zh (27+ keys each)
This commit is contained in:
@@ -25,6 +25,7 @@ from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
from .routes.gradients import router as gradients_router
|
||||
from .routes.weather_sources import router as weather_sources_router
|
||||
from .routes.update import router as update_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -50,5 +51,6 @@ router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
router.include_router(gradients_router)
|
||||
router.include_router(weather_sources_router)
|
||||
router.include_router(update_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -28,6 +28,7 @@ from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -134,6 +135,10 @@ def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
|
||||
def get_update_service() -> UpdateService:
|
||||
return _get("update_service", "Update service")
|
||||
|
||||
|
||||
# ── Event helper ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -181,6 +186,7 @@ def init_dependencies(
|
||||
gradient_store: GradientStore | None = None,
|
||||
weather_source_store: WeatherSourceStore | None = None,
|
||||
weather_manager: WeatherManager | None = None,
|
||||
update_service: UpdateService | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update({
|
||||
@@ -206,4 +212,5 @@ def init_dependencies(
|
||||
"gradient_store": gradient_store,
|
||||
"weather_source_store": weather_source_store,
|
||||
"weather_manager": weather_manager,
|
||||
"update_service": update_service,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""API routes for the auto-update system."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from wled_controller.api.dependencies import get_update_service
|
||||
from wled_controller.api.schemas.update import (
|
||||
DismissRequest,
|
||||
UpdateSettingsRequest,
|
||||
UpdateSettingsResponse,
|
||||
UpdateStatusResponse,
|
||||
)
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=UpdateStatusResponse)
|
||||
async def get_update_status(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_status()
|
||||
|
||||
|
||||
@router.post("/check", response_model=UpdateStatusResponse)
|
||||
async def check_for_updates(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return await service.check_now()
|
||||
|
||||
|
||||
@router.post("/dismiss")
|
||||
async def dismiss_update(
|
||||
body: DismissRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
service.dismiss(body.version)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_update(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
"""Download (if needed) and apply the available update."""
|
||||
status = service.get_status()
|
||||
if not status["has_update"]:
|
||||
return JSONResponse(status_code=400, content={"detail": "No update available"})
|
||||
if not status["can_auto_update"]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Auto-update not supported for install type: {status['install_type']}"},
|
||||
)
|
||||
try:
|
||||
await service.apply_update()
|
||||
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@router.get("/settings", response_model=UpdateSettingsResponse)
|
||||
async def get_update_settings(
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return service.get_settings()
|
||||
|
||||
|
||||
@router.put("/settings", response_model=UpdateSettingsResponse)
|
||||
async def update_update_settings(
|
||||
body: UpdateSettingsRequest,
|
||||
service: UpdateService = Depends(get_update_service),
|
||||
):
|
||||
return await service.update_settings(
|
||||
enabled=body.enabled,
|
||||
check_interval_hours=body.check_interval_hours,
|
||||
include_prerelease=body.include_prerelease,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Pydantic schemas for the update API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateReleaseInfo(BaseModel):
|
||||
version: str
|
||||
tag: str
|
||||
name: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
|
||||
|
||||
class UpdateStatusResponse(BaseModel):
|
||||
current_version: str
|
||||
has_update: bool
|
||||
checking: bool
|
||||
last_check: float | None
|
||||
last_error: str | None
|
||||
releases_url: str
|
||||
install_type: str
|
||||
can_auto_update: bool
|
||||
downloading: bool
|
||||
download_progress: float
|
||||
applying: bool
|
||||
release: UpdateReleaseInfo | None
|
||||
dismissed_version: str
|
||||
|
||||
|
||||
class UpdateSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
check_interval_hours: float
|
||||
include_prerelease: bool
|
||||
|
||||
|
||||
class UpdateSettingsRequest(BaseModel):
|
||||
enabled: bool
|
||||
check_interval_hours: float = Field(ge=0.5, le=168)
|
||||
include_prerelease: bool
|
||||
|
||||
|
||||
class DismissRequest(BaseModel):
|
||||
version: str
|
||||
Reference in New Issue
Block a user