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:
2026-03-25 13:16:18 +03:00
parent d2b3fdf786
commit 382a42755d
30 changed files with 1750 additions and 44 deletions
@@ -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