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:
@@ -10,10 +10,12 @@ RUN npm run build
|
||||
## Stage 2: Python application
|
||||
FROM python:3.11.11-slim AS runtime
|
||||
|
||||
ARG APP_VERSION=0.0.0
|
||||
|
||||
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
||||
LABEL org.opencontainers.image.title="LED Grab"
|
||||
LABEL org.opencontainers.image.description="Ambient lighting system that captures screen content and drives LED strips in real time"
|
||||
LABEL org.opencontainers.image.version="0.2.0"
|
||||
LABEL org.opencontainers.image.version="${APP_VERSION}"
|
||||
LABEL org.opencontainers.image.url="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
@@ -34,7 +36,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Copy pyproject.toml with a minimal package stub so pip can resolve deps.
|
||||
# The real source is copied afterward, keeping the dep layer cached.
|
||||
COPY pyproject.toml .
|
||||
RUN mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
RUN sed -i "s/^version = .*/version = \"${APP_VERSION}\"/" pyproject.toml \
|
||||
&& mkdir -p src/wled_controller && touch src/wled_controller/__init__.py \
|
||||
&& pip install --no-cache-dir ".[notifications]" \
|
||||
&& rm -rf src/wled_controller
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"httpx>=0.27.2",
|
||||
"packaging>=23.0",
|
||||
"mss>=9.0.2",
|
||||
"Pillow>=10.4.0",
|
||||
"numpy>=2.1.3",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"""LED Grab - Ambient lighting based on screen content."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("wled-screen-controller")
|
||||
except PackageNotFoundError:
|
||||
# Running from source without pip install (e.g. dev, embedded Python)
|
||||
__version__ = "0.0.0-dev"
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
81
server/src/wled_controller/api/routes/update.py
Normal file
81
server/src/wled_controller/api/routes/update.py
Normal file
@@ -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,
|
||||
)
|
||||
44
server/src/wled_controller/api/schemas/update.py
Normal file
44
server/src/wled_controller/api/schemas/update.py
Normal file
@@ -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
|
||||
1
server/src/wled_controller/core/update/__init__.py
Normal file
1
server/src/wled_controller/core/update/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auto-update — periodic release checking and notification."""
|
||||
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Gitea release provider — fetches releases from a Gitea instance."""
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GiteaReleaseProvider(ReleaseProvider):
|
||||
"""Fetch releases from a Gitea repository via its REST API."""
|
||||
|
||||
def __init__(self, base_url: str, repo: str, token: str = "") -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._repo = repo
|
||||
self._token = token
|
||||
|
||||
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||
url = f"{self._base_url}/api/v1/repos/{self._repo}/releases"
|
||||
headers: dict[str, str] = {}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"token {self._token}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, params={"limit": limit}, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
releases: list[ReleaseInfo] = []
|
||||
for item in data:
|
||||
tag = item.get("tag_name", "")
|
||||
version = tag.lstrip("v")
|
||||
assets = tuple(
|
||||
AssetInfo(
|
||||
name=a["name"],
|
||||
size=a.get("size", 0),
|
||||
download_url=a["browser_download_url"],
|
||||
)
|
||||
for a in item.get("assets", [])
|
||||
)
|
||||
releases.append(ReleaseInfo(
|
||||
tag=tag,
|
||||
version=version,
|
||||
name=item.get("name", tag),
|
||||
body=item.get("body", ""),
|
||||
prerelease=item.get("prerelease", False),
|
||||
published_at=item.get("published_at", ""),
|
||||
assets=assets,
|
||||
))
|
||||
return releases
|
||||
|
||||
def get_releases_page_url(self) -> str:
|
||||
return f"{self._base_url}/{self._repo}/releases"
|
||||
73
server/src/wled_controller/core/update/install_type.py
Normal file
73
server/src/wled_controller/core/update/install_type.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Detect how the application was installed.
|
||||
|
||||
The install type determines which update strategy is available:
|
||||
- installer: NSIS `.exe` installed to AppData — can run new installer silently
|
||||
- portable: Extracted ZIP with embedded Python — can replace app/ + python/ dirs
|
||||
- docker: Running inside a Docker container — no auto-update, show instructions
|
||||
- dev: Running from source (pip install -e) — no auto-update, link to releases
|
||||
"""
|
||||
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstallType(str, Enum):
|
||||
INSTALLER = "installer"
|
||||
PORTABLE = "portable"
|
||||
DOCKER = "docker"
|
||||
DEV = "dev"
|
||||
|
||||
|
||||
def detect_install_type() -> InstallType:
|
||||
"""Detect the current install type once at startup."""
|
||||
# Docker: /.dockerenv file or cgroup hints
|
||||
if Path("/.dockerenv").exists():
|
||||
logger.info("Install type: docker")
|
||||
return InstallType.DOCKER
|
||||
|
||||
# Windows installed/portable: look for embedded Python dir
|
||||
app_root = Path.cwd()
|
||||
has_uninstaller = (app_root / "uninstall.exe").exists()
|
||||
has_embedded_python = (app_root / "python" / "python.exe").exists()
|
||||
|
||||
if has_uninstaller:
|
||||
logger.info("Install type: installer (uninstall.exe found at %s)", app_root)
|
||||
return InstallType.INSTALLER
|
||||
|
||||
if has_embedded_python:
|
||||
logger.info("Install type: portable (embedded python/ found at %s)", app_root)
|
||||
return InstallType.PORTABLE
|
||||
|
||||
# Linux portable: look for venv/ + run.sh
|
||||
if (app_root / "venv").is_dir() and (app_root / "run.sh").exists():
|
||||
logger.info("Install type: portable (Linux venv layout at %s)", app_root)
|
||||
return InstallType.PORTABLE
|
||||
|
||||
logger.info("Install type: dev (no distribution markers found)")
|
||||
return InstallType.DEV
|
||||
|
||||
|
||||
def get_platform_asset_pattern(install_type: InstallType) -> str | None:
|
||||
"""Return a substring that the matching release asset name must contain.
|
||||
|
||||
Returns None if auto-update is not supported for this install type.
|
||||
"""
|
||||
if install_type == InstallType.DOCKER:
|
||||
return None
|
||||
if install_type == InstallType.DEV:
|
||||
return None
|
||||
|
||||
if sys.platform == "win32":
|
||||
if install_type == InstallType.INSTALLER:
|
||||
return "-setup.exe"
|
||||
return "-win-x64.zip"
|
||||
|
||||
if sys.platform == "linux":
|
||||
return "-linux-x64.tar.gz"
|
||||
|
||||
return None
|
||||
41
server/src/wled_controller/core/update/release_provider.py
Normal file
41
server/src/wled_controller/core/update/release_provider.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Abstract release provider and data models."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetInfo:
|
||||
"""A single downloadable asset attached to a release."""
|
||||
|
||||
name: str
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInfo:
|
||||
"""A single release from the hosting platform."""
|
||||
|
||||
tag: str
|
||||
version: str
|
||||
name: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
assets: tuple[AssetInfo, ...]
|
||||
|
||||
|
||||
class ReleaseProvider(ABC):
|
||||
"""Platform-agnostic interface for querying releases.
|
||||
|
||||
Implement this for Gitea, GitHub, GitLab, etc.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||
"""Fetch recent releases, newest first."""
|
||||
|
||||
@abstractmethod
|
||||
def get_releases_page_url(self) -> str:
|
||||
"""Return the user-facing URL of the releases page."""
|
||||
518
server/src/wled_controller/core/update/update_service.py
Normal file
518
server/src/wled_controller/core/update/update_service.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""Background service that periodically checks for new releases."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.core.update.install_type import InstallType, detect_install_type, get_platform_asset_pattern
|
||||
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||
from wled_controller.core.update.version_check import is_newer, normalize_version
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"check_interval_hours": 24.0,
|
||||
"include_prerelease": False,
|
||||
}
|
||||
|
||||
_STARTUP_DELAY_S = 30
|
||||
_MANUAL_CHECK_DEBOUNCE_S = 60
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: ReleaseProvider,
|
||||
db: Database,
|
||||
fire_event: Any = None,
|
||||
update_dir: Path | None = None,
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._db = db
|
||||
self._fire_event = fire_event
|
||||
|
||||
self._settings = self._load_settings()
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
# Install type (detected once)
|
||||
self._install_type = detect_install_type()
|
||||
self._asset_pattern = get_platform_asset_pattern(self._install_type)
|
||||
|
||||
# Download directory
|
||||
self._update_dir = update_dir or Path("data/updates")
|
||||
self._update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Runtime state
|
||||
self._available_release: ReleaseInfo | None = None
|
||||
self._last_check: float = 0.0
|
||||
self._checking = False
|
||||
self._last_error: str | None = None
|
||||
|
||||
# Download/apply state
|
||||
self._download_progress: float = 0.0 # 0..1
|
||||
self._downloading = False
|
||||
self._downloaded_file: Path | None = None
|
||||
self._applying = False
|
||||
|
||||
# Load persisted state
|
||||
persisted = self._db.get_setting("update_state") or {}
|
||||
self._dismissed_version: str = persisted.get("dismissed_version", "")
|
||||
|
||||
# ── Settings persistence ───────────────────────────────────
|
||||
|
||||
def _load_settings(self) -> dict:
|
||||
data = self._db.get_setting("auto_update")
|
||||
if data:
|
||||
return {**DEFAULT_SETTINGS, **data}
|
||||
return dict(DEFAULT_SETTINGS)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
self._db.set_setting("auto_update", {
|
||||
"enabled": self._settings["enabled"],
|
||||
"check_interval_hours": self._settings["check_interval_hours"],
|
||||
"include_prerelease": self._settings["include_prerelease"],
|
||||
})
|
||||
|
||||
def _save_state(self) -> None:
|
||||
self._db.set_setting("update_state", {
|
||||
"dismissed_version": self._dismissed_version,
|
||||
})
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._settings["enabled"]:
|
||||
self._start_loop()
|
||||
logger.info(
|
||||
"Update checker started (every %.1fh, prerelease=%s, install=%s)",
|
||||
self._settings["check_interval_hours"],
|
||||
self._settings["include_prerelease"],
|
||||
self._install_type.value,
|
||||
)
|
||||
else:
|
||||
logger.info("Update checker initialized (disabled, install=%s)", self._install_type.value)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._cancel_loop()
|
||||
logger.info("Update checker stopped")
|
||||
|
||||
def _start_loop(self) -> None:
|
||||
self._cancel_loop()
|
||||
self._task = asyncio.create_task(self._check_loop())
|
||||
|
||||
def _cancel_loop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
async def _check_loop(self) -> None:
|
||||
try:
|
||||
await asyncio.sleep(_STARTUP_DELAY_S)
|
||||
await self._perform_check()
|
||||
|
||||
interval_s = self._settings["check_interval_hours"] * 3600
|
||||
while True:
|
||||
await asyncio.sleep(interval_s)
|
||||
try:
|
||||
await self._perform_check()
|
||||
except Exception as exc:
|
||||
logger.error("Update check failed: %s", exc, exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ── Core check logic ───────────────────────────────────────
|
||||
|
||||
async def _perform_check(self) -> None:
|
||||
self._checking = True
|
||||
self._last_error = None
|
||||
try:
|
||||
releases = await self._provider.get_releases(limit=10)
|
||||
best = self._find_best_release(releases)
|
||||
self._available_release = best
|
||||
self._last_check = time.time()
|
||||
|
||||
if best and self._fire_event:
|
||||
self._fire_event({
|
||||
"type": "update_available",
|
||||
"version": best.version,
|
||||
"tag": best.tag,
|
||||
"name": best.name,
|
||||
"prerelease": best.prerelease,
|
||||
"dismissed": best.version == self._dismissed_version,
|
||||
"can_auto_update": self._can_auto_update(best),
|
||||
})
|
||||
logger.info(
|
||||
"Update check complete — %s",
|
||||
f"v{best.version} available" if best else "up to date",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
self._checking = False
|
||||
|
||||
def _find_best_release(self, releases: list[ReleaseInfo]) -> ReleaseInfo | None:
|
||||
"""Find the newest release that is newer than the current version."""
|
||||
include_pre = self._settings["include_prerelease"]
|
||||
for release in releases:
|
||||
if release.prerelease and not include_pre:
|
||||
continue
|
||||
try:
|
||||
normalize_version(release.version)
|
||||
except Exception:
|
||||
continue
|
||||
if is_newer(release.version, __version__):
|
||||
return release
|
||||
return None
|
||||
|
||||
def _find_asset(self, release: ReleaseInfo) -> AssetInfo | None:
|
||||
"""Find the matching asset for this platform + install type."""
|
||||
if not self._asset_pattern:
|
||||
return None
|
||||
for asset in release.assets:
|
||||
if self._asset_pattern in asset.name:
|
||||
return asset
|
||||
return None
|
||||
|
||||
def _can_auto_update(self, release: ReleaseInfo) -> bool:
|
||||
"""Check if auto-update is possible for the given release."""
|
||||
return self._find_asset(release) is not None
|
||||
|
||||
# ── Download ───────────────────────────────────────────────
|
||||
|
||||
async def download_update(self) -> Path:
|
||||
"""Download the update asset. Returns path to downloaded file."""
|
||||
release = self._available_release
|
||||
if not release:
|
||||
raise RuntimeError("No update available")
|
||||
|
||||
asset = self._find_asset(release)
|
||||
if not asset:
|
||||
raise RuntimeError(
|
||||
f"No matching asset for {self._install_type.value} "
|
||||
f"on {sys.platform}"
|
||||
)
|
||||
|
||||
dest = self._update_dir / asset.name
|
||||
# Skip re-download if file exists and size matches
|
||||
if dest.exists() and dest.stat().st_size == asset.size:
|
||||
logger.info("Update already downloaded: %s", dest.name)
|
||||
self._downloaded_file = dest
|
||||
self._download_progress = 1.0
|
||||
return dest
|
||||
|
||||
self._downloading = True
|
||||
self._download_progress = 0.0
|
||||
try:
|
||||
await self._stream_download(asset.download_url, dest, asset.size)
|
||||
self._downloaded_file = dest
|
||||
logger.info("Downloaded update: %s (%d bytes)", dest.name, dest.stat().st_size)
|
||||
return dest
|
||||
except Exception:
|
||||
# Clean up partial download
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
self._downloaded_file = None
|
||||
raise
|
||||
finally:
|
||||
self._downloading = False
|
||||
|
||||
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
|
||||
"""Stream-download a file, updating progress as we go."""
|
||||
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||
received = 0
|
||||
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
received += len(chunk)
|
||||
if total_size > 0:
|
||||
self._download_progress = received / total_size
|
||||
if self._fire_event:
|
||||
self._fire_event({
|
||||
"type": "update_download_progress",
|
||||
"progress": round(self._download_progress, 3),
|
||||
})
|
||||
# Atomic rename
|
||||
tmp.replace(dest)
|
||||
self._download_progress = 1.0
|
||||
|
||||
# ── Apply ──────────────────────────────────────────────────
|
||||
|
||||
async def apply_update(self) -> None:
|
||||
"""Download (if needed) and apply the update, then shut down."""
|
||||
if self._applying:
|
||||
raise RuntimeError("Update already in progress")
|
||||
self._applying = True
|
||||
try:
|
||||
if not self._downloaded_file or not self._downloaded_file.exists():
|
||||
await self.download_update()
|
||||
|
||||
assert self._downloaded_file is not None
|
||||
file_path = self._downloaded_file
|
||||
|
||||
if self._install_type == InstallType.INSTALLER:
|
||||
await self._apply_installer(file_path)
|
||||
elif self._install_type == InstallType.PORTABLE:
|
||||
if file_path.suffix == ".zip":
|
||||
await self._apply_portable_zip(file_path)
|
||||
elif file_path.name.endswith(".tar.gz"):
|
||||
await self._apply_portable_tarball(file_path)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown portable format: {file_path.name}")
|
||||
else:
|
||||
raise RuntimeError(f"Auto-update not supported for install type: {self._install_type.value}")
|
||||
finally:
|
||||
self._applying = False
|
||||
|
||||
async def _apply_installer(self, exe_path: Path) -> None:
|
||||
"""Launch the NSIS installer silently and shut down."""
|
||||
install_dir = str(Path.cwd())
|
||||
logger.info("Launching silent installer: %s /S /D=%s", exe_path, install_dir)
|
||||
|
||||
# Fire event so frontend shows restart overlay
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
# Launch installer detached — it will wait for python.exe to exit,
|
||||
# then install and the VBS launcher / service will restart the app.
|
||||
subprocess.Popen(
|
||||
[str(exe_path), "/S", f"/D={install_dir}"],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
# Give the installer a moment to start, then shut down
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
async def _apply_portable_zip(self, zip_path: Path) -> None:
|
||||
"""Extract ZIP over the current installation, then shut down."""
|
||||
import zipfile
|
||||
|
||||
app_root = Path.cwd()
|
||||
staging = self._update_dir / "_staging"
|
||||
|
||||
logger.info("Extracting portable update: %s", zip_path.name)
|
||||
|
||||
# Extract to staging dir in a thread (I/O bound)
|
||||
def _extract():
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(staging)
|
||||
|
||||
await asyncio.to_thread(_extract)
|
||||
|
||||
# The ZIP contains a top-level LedGrab/ dir — find it
|
||||
inner = _find_single_child_dir(staging)
|
||||
|
||||
# Write a post-update script that swaps the dirs after shutdown.
|
||||
# On Windows, python.exe locks files, so we need a bat script
|
||||
# that waits for the process to exit, then does the swap.
|
||||
script = self._update_dir / "_apply_update.bat"
|
||||
script.write_text(
|
||||
_build_swap_script(inner, app_root, ["app", "python", "scripts"]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logger.info("Launching post-update script and shutting down")
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
subprocess.Popen(
|
||||
["cmd.exe", "/c", str(script)],
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
async def _apply_portable_tarball(self, tar_path: Path) -> None:
|
||||
"""Extract tarball over the current installation, then shut down."""
|
||||
import tarfile
|
||||
|
||||
app_root = Path.cwd()
|
||||
staging = self._update_dir / "_staging"
|
||||
|
||||
logger.info("Extracting portable update: %s", tar_path.name)
|
||||
|
||||
def _extract():
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True)
|
||||
with tarfile.open(tar_path, "r:gz") as tf:
|
||||
tf.extractall(staging, filter="data")
|
||||
|
||||
await asyncio.to_thread(_extract)
|
||||
|
||||
inner = _find_single_child_dir(staging)
|
||||
|
||||
# On Linux, write a shell script that replaces dirs after shutdown
|
||||
script = self._update_dir / "_apply_update.sh"
|
||||
dirs_to_swap = ["app", "venv"]
|
||||
lines = [
|
||||
"#!/bin/bash",
|
||||
"# Auto-generated update script — replaces app dirs and restarts",
|
||||
f'APP_ROOT="{app_root}"',
|
||||
f'STAGING="{inner}"',
|
||||
"sleep 3 # wait for server to exit",
|
||||
]
|
||||
for d in dirs_to_swap:
|
||||
lines.append(f'[ -d "$STAGING/{d}" ] && rm -rf "$APP_ROOT/{d}" && mv "$STAGING/{d}" "$APP_ROOT/{d}"')
|
||||
# Copy scripts/ and run.sh if present
|
||||
lines.append('[ -f "$STAGING/run.sh" ] && cp "$STAGING/run.sh" "$APP_ROOT/run.sh"')
|
||||
lines.append(f'rm -rf "{staging}"')
|
||||
lines.append(f'rm -f "{script}"')
|
||||
lines.append('echo "Update applied. Restarting..."')
|
||||
lines.append('cd "$APP_ROOT" && exec ./run.sh')
|
||||
script.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
os.chmod(script, 0o755)
|
||||
|
||||
logger.info("Launching post-update script and shutting down")
|
||||
if self._fire_event:
|
||||
self._fire_event({"type": "server_restarting"})
|
||||
|
||||
subprocess.Popen(
|
||||
["/bin/bash", str(script)],
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
from wled_controller.server_ref import request_shutdown
|
||||
request_shutdown()
|
||||
|
||||
# ── Public API (called from routes) ────────────────────────
|
||||
|
||||
async def check_now(self) -> dict:
|
||||
"""Trigger an immediate check (with debounce)."""
|
||||
elapsed = time.time() - self._last_check
|
||||
if elapsed < _MANUAL_CHECK_DEBOUNCE_S and self._available_release is not None:
|
||||
return self.get_status()
|
||||
await self._perform_check()
|
||||
return self.get_status()
|
||||
|
||||
def dismiss(self, version: str) -> None:
|
||||
"""Dismiss the notification for *version*."""
|
||||
self._dismissed_version = version
|
||||
self._save_state()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
rel = self._available_release
|
||||
can_auto = rel is not None and self._can_auto_update(rel)
|
||||
return {
|
||||
"current_version": __version__,
|
||||
"has_update": rel is not None,
|
||||
"checking": self._checking,
|
||||
"last_check": self._last_check if self._last_check else None,
|
||||
"last_error": self._last_error,
|
||||
"releases_url": self._provider.get_releases_page_url(),
|
||||
"install_type": self._install_type.value,
|
||||
"can_auto_update": can_auto,
|
||||
"downloading": self._downloading,
|
||||
"download_progress": round(self._download_progress, 3),
|
||||
"applying": self._applying,
|
||||
"release": {
|
||||
"version": rel.version,
|
||||
"tag": rel.tag,
|
||||
"name": rel.name,
|
||||
"body": rel.body,
|
||||
"prerelease": rel.prerelease,
|
||||
"published_at": rel.published_at,
|
||||
} if rel else None,
|
||||
"dismissed_version": self._dismissed_version,
|
||||
}
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
return {
|
||||
"enabled": self._settings["enabled"],
|
||||
"check_interval_hours": self._settings["check_interval_hours"],
|
||||
"include_prerelease": self._settings["include_prerelease"],
|
||||
}
|
||||
|
||||
async def update_settings(
|
||||
self,
|
||||
enabled: bool,
|
||||
check_interval_hours: float,
|
||||
include_prerelease: bool,
|
||||
) -> dict:
|
||||
self._settings["enabled"] = enabled
|
||||
self._settings["check_interval_hours"] = check_interval_hours
|
||||
self._settings["include_prerelease"] = include_prerelease
|
||||
self._save_settings()
|
||||
|
||||
if enabled:
|
||||
self._start_loop()
|
||||
logger.info(
|
||||
"Update checker enabled (every %.1fh, prerelease=%s)",
|
||||
check_interval_hours,
|
||||
include_prerelease,
|
||||
)
|
||||
else:
|
||||
self._cancel_loop()
|
||||
logger.info("Update checker disabled")
|
||||
|
||||
return self.get_settings()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_single_child_dir(parent: Path) -> Path:
|
||||
"""Return the single subdirectory inside *parent* (e.g. LedGrab/)."""
|
||||
children = [c for c in parent.iterdir() if c.is_dir()]
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
return parent
|
||||
|
||||
|
||||
def _build_swap_script(staging: Path, app_root: Path, dirs: list[str]) -> str:
|
||||
"""Build a Windows batch script that replaces dirs after the server exits."""
|
||||
lines = [
|
||||
"@echo off",
|
||||
"REM Auto-generated update script — replaces app dirs and restarts",
|
||||
"echo Waiting for server to exit...",
|
||||
"timeout /t 5 /nobreak >nul",
|
||||
]
|
||||
for d in dirs:
|
||||
src = staging / d
|
||||
dst = app_root / d
|
||||
lines.append(f'if exist "{src}" (')
|
||||
lines.append(f' rmdir /s /q "{dst}" 2>nul')
|
||||
lines.append(f' move /y "{src}" "{dst}"')
|
||||
lines.append(")")
|
||||
# Copy LedGrab.bat if present
|
||||
bat = staging / "LedGrab.bat"
|
||||
lines.append(f'if exist "{bat}" copy /y "{bat}" "{app_root / "LedGrab.bat"}"')
|
||||
# Cleanup
|
||||
lines.append(f'rmdir /s /q "{staging.parent}" 2>nul')
|
||||
lines.append('del /f /q "%~f0" 2>nul')
|
||||
lines.append('echo Update complete. Restarting...')
|
||||
# Restart via VBS launcher or bat
|
||||
vbs = app_root / "scripts" / "start-hidden.vbs"
|
||||
bat_launcher = app_root / "LedGrab.bat"
|
||||
lines.append(f'if exist "{vbs}" (')
|
||||
lines.append(f' start "" wscript.exe "{vbs}"')
|
||||
lines.append(") else (")
|
||||
lines.append(f' start "" "{bat_launcher}"')
|
||||
lines.append(")")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
45
server/src/wled_controller/core/update/version_check.py
Normal file
45
server/src/wled_controller/core/update/version_check.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Version comparison utilities.
|
||||
|
||||
Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
|
||||
so that ``packaging.version.Version`` can compare them correctly.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
|
||||
_PRE_MAP = {
|
||||
"alpha": "a",
|
||||
"beta": "b",
|
||||
"rc": "rc",
|
||||
}
|
||||
|
||||
_PRE_PATTERN = re.compile(
|
||||
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def normalize_version(raw: str) -> Version:
|
||||
"""Convert a tag like ``v0.3.0-alpha.1`` to a PEP 440 ``Version``.
|
||||
|
||||
Raises ``InvalidVersion`` if the string cannot be parsed.
|
||||
"""
|
||||
cleaned = raw.lstrip("v").strip()
|
||||
m = _PRE_PATTERN.match(cleaned)
|
||||
if m:
|
||||
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
|
||||
pep_label = _PRE_MAP.get(pre_label, pre_label)
|
||||
cleaned = f"{base}{pep_label}{pre_num}"
|
||||
return Version(cleaned)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
"""Return True if *candidate* is strictly newer than *current*.
|
||||
|
||||
Returns False if either version string is unparseable.
|
||||
"""
|
||||
try:
|
||||
return normalize_version(candidate) > normalize_version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
@@ -41,6 +41,8 @@ from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.core.update.gitea_provider import GiteaReleaseProvider
|
||||
from wled_controller.storage.database import Database
|
||||
from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
|
||||
|
||||
@@ -167,6 +169,18 @@ async def lifespan(app: FastAPI):
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Create update service (checks for new releases)
|
||||
_release_provider = GiteaReleaseProvider(
|
||||
base_url="https://git.dolgolyov-family.by",
|
||||
repo="alexei.dolgolyov/wled-screen-controller-mixed",
|
||||
)
|
||||
update_service = UpdateService(
|
||||
provider=_release_provider,
|
||||
db=db,
|
||||
fire_event=processor_manager.fire_event,
|
||||
update_dir=_data_dir / "updates",
|
||||
)
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(
|
||||
device_store, template_store, processor_manager,
|
||||
@@ -189,6 +203,7 @@ async def lifespan(app: FastAPI):
|
||||
gradient_store=gradient_store,
|
||||
weather_source_store=weather_source_store,
|
||||
weather_manager=weather_manager,
|
||||
update_service=update_service,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
@@ -235,6 +250,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start auto-backup engine (periodic configuration backups)
|
||||
await auto_backup_engine.start()
|
||||
|
||||
# Start update checker (periodic release polling)
|
||||
await update_service.start()
|
||||
|
||||
# Start OS notification listener (Windows toast → notification CSS streams)
|
||||
os_notif_listener = OsNotificationListener(
|
||||
color_strip_store=color_strip_store,
|
||||
@@ -258,6 +276,12 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping weather manager: {e}")
|
||||
|
||||
# Stop update checker
|
||||
try:
|
||||
await update_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping update checker: {e}")
|
||||
|
||||
# Stop auto-backup engine
|
||||
try:
|
||||
await auto_backup_engine.stop()
|
||||
|
||||
@@ -109,6 +109,58 @@ h2 {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
transition: background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
#server-version.has-update {
|
||||
background: var(--warning-color);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
animation: updatePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes updatePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); }
|
||||
}
|
||||
|
||||
/* ── Update banner ── */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
animation: bannerSlideDown 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.update-banner-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.update-banner-action {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.update-banner-action:hover {
|
||||
color: var(--primary-color);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@keyframes bannerSlideDown {
|
||||
from { transform: translateY(-100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
|
||||
@@ -199,6 +199,11 @@ import {
|
||||
loadLogLevel, setLogLevel,
|
||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||
} from './features/settings.ts';
|
||||
import {
|
||||
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
|
||||
initUpdateSettingsPanel, applyUpdate,
|
||||
} from './features/update.ts';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
|
||||
@@ -560,6 +565,14 @@ Object.assign(window, {
|
||||
saveExternalUrl,
|
||||
getBaseOrigin,
|
||||
|
||||
// update
|
||||
checkForUpdates,
|
||||
loadUpdateSettings,
|
||||
saveUpdateSettings,
|
||||
dismissUpdate,
|
||||
initUpdateSettingsPanel,
|
||||
applyUpdate,
|
||||
|
||||
// appearance
|
||||
applyStylePreset,
|
||||
applyBgEffect,
|
||||
@@ -703,6 +716,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
startEntityEventListeners();
|
||||
startAutoRefresh();
|
||||
|
||||
// Initialize update checker (banner + WS listener)
|
||||
initUpdateListener();
|
||||
loadUpdateStatus();
|
||||
|
||||
// Show getting-started tutorial on first visit
|
||||
if (!localStorage.getItem('tour_completed')) {
|
||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||
|
||||
@@ -81,3 +81,5 @@ export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2
|
||||
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
|
||||
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
|
||||
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
||||
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
|
||||
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
|
||||
|
||||
@@ -183,3 +183,5 @@ export const ICON_HEADPHONES = _svg(P.headphones);
|
||||
export const ICON_TRASH = _svg(P.trash2);
|
||||
export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||
export const ICON_X = _svg(P.xIcon);
|
||||
|
||||
@@ -76,6 +76,11 @@ export function switchSettingsTab(tabId: string): void {
|
||||
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
|
||||
window.renderAppearanceTab();
|
||||
}
|
||||
// Lazy-load update settings
|
||||
if (tabId === 'updates' && typeof (window as any).loadUpdateSettings === 'function') {
|
||||
(window as any).initUpdateSettingsPanel();
|
||||
(window as any).loadUpdateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
400
server/src/wled_controller/static/js/features/update.ts
Normal file
400
server/src/wled_controller/static/js/features/update.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Auto-update — check for new releases, show banner, manage settings.
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '../core/api.ts';
|
||||
import { showToast, showConfirm } from '../core/ui.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { IconSelect } from '../core/icon-select.ts';
|
||||
import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────
|
||||
|
||||
interface UpdateRelease {
|
||||
version: string;
|
||||
tag: string;
|
||||
name: string;
|
||||
body: string;
|
||||
prerelease: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface UpdateStatus {
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
checking: boolean;
|
||||
last_check: number | null;
|
||||
last_error: string | null;
|
||||
releases_url: string;
|
||||
install_type: string;
|
||||
can_auto_update: boolean;
|
||||
downloading: boolean;
|
||||
download_progress: number;
|
||||
applying: boolean;
|
||||
release: UpdateRelease | null;
|
||||
dismissed_version: string;
|
||||
}
|
||||
|
||||
let _lastStatus: UpdateStatus | null = null;
|
||||
|
||||
// ─── Version badge highlight ────────────────────────────────
|
||||
|
||||
function _setVersionBadgeUpdate(hasUpdate: boolean): void {
|
||||
const badge = document.getElementById('server-version');
|
||||
if (!badge) return;
|
||||
badge.classList.toggle('has-update', hasUpdate);
|
||||
|
||||
if (hasUpdate) {
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.title = t('update.badge_tooltip');
|
||||
badge.onclick = () => switchSettingsTabToUpdate();
|
||||
} else {
|
||||
badge.style.cursor = '';
|
||||
badge.title = '';
|
||||
badge.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTabToUpdate(): void {
|
||||
if (typeof (window as any).openSettingsModal === 'function') {
|
||||
(window as any).openSettingsModal();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (typeof (window as any).switchSettingsTab === 'function') {
|
||||
(window as any).switchSettingsTab('updates');
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// ─── Update banner ──────────────────────────────────────────
|
||||
|
||||
function _showBanner(status: UpdateStatus): void {
|
||||
const release = status.release;
|
||||
if (!release) return;
|
||||
|
||||
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||
if (dismissed === release.version) return;
|
||||
|
||||
const banner = document.getElementById('update-banner');
|
||||
if (!banner) return;
|
||||
|
||||
const versionLabel = release.prerelease
|
||||
? `${release.version} (${t('update.prerelease')})`
|
||||
: release.version;
|
||||
|
||||
let actions = '';
|
||||
|
||||
// "Update Now" button if auto-update is supported
|
||||
if (status.can_auto_update) {
|
||||
actions += `<button class="btn btn-icon update-banner-action update-banner-apply" onclick="applyUpdate()" title="${t('update.apply_now')}">
|
||||
${ICON_DOWNLOAD}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
|
||||
${ICON_EXTERNAL_LINK}
|
||||
</a>`;
|
||||
|
||||
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
|
||||
${ICON_X}
|
||||
</button>`;
|
||||
|
||||
banner.innerHTML = `
|
||||
<span class="update-banner-text">
|
||||
${t('update.available').replace('{version}', versionLabel)}
|
||||
</span>
|
||||
${actions}
|
||||
`;
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
|
||||
function _hideBanner(): void {
|
||||
const banner = document.getElementById('update-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
}
|
||||
|
||||
export function dismissUpdate(): void {
|
||||
if (!_lastStatus?.release) return;
|
||||
const version = _lastStatus.release.version;
|
||||
localStorage.setItem('update-dismissed-version', version);
|
||||
_hideBanner();
|
||||
_setVersionBadgeUpdate(false);
|
||||
|
||||
fetchWithAuth('/system/update/dismiss', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── Apply update ───────────────────────────────────────────
|
||||
|
||||
export async function applyUpdate(): Promise<void> {
|
||||
if (!_lastStatus?.release) return;
|
||||
|
||||
const version = _lastStatus.release.version;
|
||||
const confirmed = await showConfirm(
|
||||
t('update.apply_confirm').replace('{version}', version)
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
// Disable the apply button
|
||||
const btns = document.querySelectorAll('.update-banner-apply, #update-apply-btn');
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/apply', {
|
||||
method: 'POST',
|
||||
timeout: 600000, // 10 min for download + apply
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||
showToast(t('update.applying'), 'info');
|
||||
} catch (err) {
|
||||
showToast(t('update.apply_error') + ': ' + (err as Error).message, 'error');
|
||||
btns.forEach(b => (b as HTMLButtonElement).disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status fetch (called on page load + WS event) ─────────
|
||||
|
||||
export async function loadUpdateStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/status');
|
||||
if (!resp.ok) return;
|
||||
const status: UpdateStatus = await resp.json();
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
} catch {
|
||||
// silent — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function _applyStatus(status: UpdateStatus): void {
|
||||
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||
const hasVisibleUpdate = status.has_update
|
||||
&& status.release != null
|
||||
&& status.release.version !== dismissed;
|
||||
|
||||
_setVersionBadgeUpdate(hasVisibleUpdate);
|
||||
|
||||
if (hasVisibleUpdate) {
|
||||
_showBanner(status);
|
||||
} else {
|
||||
_hideBanner();
|
||||
}
|
||||
|
||||
_renderUpdatePanel(status);
|
||||
}
|
||||
|
||||
// ─── WS event handlers ─────────────────────────────────────
|
||||
|
||||
export function initUpdateListener(): void {
|
||||
document.addEventListener('server:update_available', ((e: CustomEvent) => {
|
||||
const data = e.detail;
|
||||
if (data && data.version && !data.dismissed) {
|
||||
loadUpdateStatus();
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
// Download progress
|
||||
document.addEventListener('server:update_download_progress', ((e: CustomEvent) => {
|
||||
const progress = e.detail?.progress;
|
||||
if (typeof progress === 'number') {
|
||||
_updateProgressBar(progress);
|
||||
}
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
function _updateProgressBar(progress: number): void {
|
||||
const bar = document.getElementById('update-progress-bar');
|
||||
if (bar) {
|
||||
bar.style.width = `${Math.round(progress * 100)}%`;
|
||||
bar.parentElement!.style.display = progress > 0 && progress < 1 ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual check ──────────────────────────────────────────
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
const btn = document.getElementById('update-check-btn') as HTMLButtonElement | null;
|
||||
const spinner = document.getElementById('update-check-spinner');
|
||||
if (btn) btn.disabled = true;
|
||||
if (spinner) spinner.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const status: UpdateStatus = await resp.json();
|
||||
_lastStatus = status;
|
||||
_applyStatus(status);
|
||||
|
||||
if (status.has_update && status.release) {
|
||||
showToast(t('update.available').replace('{version}', status.release.version), 'info');
|
||||
} else {
|
||||
showToast(t('update.up_to_date'), 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('update.check_error') + ': ' + (err as Error).message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings panel ────────────────────────────────────────
|
||||
|
||||
let _channelIconSelect: IconSelect | null = null;
|
||||
|
||||
function _getChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||
return [
|
||||
{
|
||||
value: 'false',
|
||||
icon: '<span style="color:#4CAF50;font-weight:700">S</span>',
|
||||
label: t('update.channel.stable'),
|
||||
desc: t('update.channel.stable_desc'),
|
||||
},
|
||||
{
|
||||
value: 'true',
|
||||
icon: '<span style="color:#ff9800;font-weight:700">P</span>',
|
||||
label: t('update.channel.prerelease'),
|
||||
desc: t('update.channel.prerelease_desc'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function initUpdateSettingsPanel(): void {
|
||||
if (!_channelIconSelect) {
|
||||
const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||
if (sel) {
|
||||
_channelIconSelect = new IconSelect({
|
||||
target: sel,
|
||||
items: _getChannelItems(),
|
||||
columns: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUpdateSettings(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
|
||||
const channelEl = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||
|
||||
if (enabledEl) enabledEl.checked = data.enabled;
|
||||
if (intervalEl) intervalEl.value = String(data.check_interval_hours);
|
||||
if (_channelIconSelect) {
|
||||
_channelIconSelect.setValue(String(data.include_prerelease));
|
||||
} else if (channelEl) {
|
||||
channelEl.value = String(data.include_prerelease);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load update settings:', err);
|
||||
}
|
||||
|
||||
await loadUpdateStatus();
|
||||
}
|
||||
|
||||
export async function saveUpdateSettings(): Promise<void> {
|
||||
const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
|
||||
const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
|
||||
const check_interval_hours = parseFloat(intervalStr);
|
||||
const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
|
||||
const include_prerelease = channelVal === 'true';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/update/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('update.settings_saved'), 'success');
|
||||
} catch (err) {
|
||||
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _renderUpdatePanel(status: UpdateStatus): void {
|
||||
const currentEl = document.getElementById('update-current-version');
|
||||
if (currentEl) currentEl.textContent = `v${status.current_version}`;
|
||||
|
||||
const statusEl = document.getElementById('update-status-text');
|
||||
if (statusEl) {
|
||||
if (status.has_update && status.release) {
|
||||
statusEl.textContent = t('update.available').replace('{version}', status.release.version);
|
||||
statusEl.style.color = 'var(--warning-color)';
|
||||
} else if (status.last_error) {
|
||||
statusEl.textContent = t('update.check_error') + ': ' + status.last_error;
|
||||
statusEl.style.color = 'var(--danger-color)';
|
||||
} else {
|
||||
statusEl.textContent = t('update.up_to_date');
|
||||
statusEl.style.color = 'var(--primary-color)';
|
||||
}
|
||||
}
|
||||
|
||||
const lastCheckEl = document.getElementById('update-last-check');
|
||||
if (lastCheckEl) {
|
||||
if (status.last_check) {
|
||||
lastCheckEl.textContent = t('update.last_check') + ': ' + new Date(status.last_check * 1000).toLocaleString();
|
||||
} else {
|
||||
lastCheckEl.textContent = t('update.last_check') + ': ' + t('update.never');
|
||||
}
|
||||
}
|
||||
|
||||
// Install type info
|
||||
const installEl = document.getElementById('update-install-type');
|
||||
if (installEl) {
|
||||
installEl.textContent = t(`update.install_type.${status.install_type}`);
|
||||
}
|
||||
|
||||
// "Update Now" button in settings panel
|
||||
const applyBtn = document.getElementById('update-apply-btn') as HTMLButtonElement | null;
|
||||
if (applyBtn) {
|
||||
const show = status.has_update && status.can_auto_update;
|
||||
applyBtn.style.display = show ? '' : 'none';
|
||||
applyBtn.disabled = status.downloading || status.applying;
|
||||
if (status.downloading) {
|
||||
applyBtn.textContent = `${t('update.downloading')} ${Math.round(status.download_progress * 100)}%`;
|
||||
} else if (status.applying) {
|
||||
applyBtn.textContent = t('update.applying');
|
||||
} else {
|
||||
applyBtn.textContent = t('update.apply_now');
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
const progressBar = document.getElementById('update-progress-bar');
|
||||
if (progressBar) {
|
||||
const show = status.downloading && status.download_progress > 0 && status.download_progress < 1;
|
||||
progressBar.style.width = `${Math.round(status.download_progress * 100)}%`;
|
||||
progressBar.parentElement!.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
// Release notes preview
|
||||
const notesEl = document.getElementById('update-release-notes');
|
||||
if (notesEl) {
|
||||
if (status.has_update && status.release && status.release.body) {
|
||||
const truncated = status.release.body.length > 500
|
||||
? status.release.body.slice(0, 500) + '...'
|
||||
: status.release.body;
|
||||
notesEl.textContent = truncated;
|
||||
notesEl.parentElement!.style.display = '';
|
||||
} else {
|
||||
notesEl.textContent = '';
|
||||
notesEl.parentElement!.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1917,6 +1917,43 @@
|
||||
"appearance.bg.scanlines": "Scanlines",
|
||||
"appearance.bg.applied": "Background effect applied",
|
||||
|
||||
"settings.tab.updates": "Updates",
|
||||
"update.status_label": "Update Status",
|
||||
"update.current_version": "Current version:",
|
||||
"update.badge_tooltip": "New version available — click for details",
|
||||
"update.available": "Version {version} is available",
|
||||
"update.up_to_date": "You are running the latest version",
|
||||
"update.prerelease": "pre-release",
|
||||
"update.view_release": "View Release",
|
||||
"update.dismiss": "Dismiss",
|
||||
"update.check_now": "Check for Updates",
|
||||
"update.check_error": "Update check failed",
|
||||
"update.last_check": "Last check",
|
||||
"update.never": "never",
|
||||
"update.release_notes": "Release Notes",
|
||||
"update.auto_check_label": "Auto-Check Settings",
|
||||
"update.auto_check_hint": "Periodically check for new releases in the background.",
|
||||
"update.enable": "Enable auto-check",
|
||||
"update.interval_label": "Check interval",
|
||||
"update.channel_label": "Channel",
|
||||
"update.channel.stable": "Stable",
|
||||
"update.channel.stable_desc": "Stable releases only",
|
||||
"update.channel.prerelease": "Pre-release",
|
||||
"update.channel.prerelease_desc": "Include alpha, beta, and RC builds",
|
||||
"update.save_settings": "Save Settings",
|
||||
"update.settings_saved": "Update settings saved",
|
||||
"update.settings_save_error": "Failed to save update settings",
|
||||
"update.apply_now": "Update Now",
|
||||
"update.apply_confirm": "Download and install version {version}? The server will restart automatically.",
|
||||
"update.apply_error": "Update failed",
|
||||
"update.applying": "Applying update…",
|
||||
"update.downloading": "Downloading…",
|
||||
"update.install_type_label": "Install type:",
|
||||
"update.install_type.installer": "Windows installer",
|
||||
"update.install_type.portable": "Portable",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Development",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Search notification apps…"
|
||||
|
||||
@@ -1846,6 +1846,43 @@
|
||||
"appearance.bg.scanlines": "Развёртка",
|
||||
"appearance.bg.applied": "Фоновый эффект применён",
|
||||
|
||||
"settings.tab.updates": "Обновления",
|
||||
"update.status_label": "Статус обновления",
|
||||
"update.current_version": "Текущая версия:",
|
||||
"update.badge_tooltip": "Доступна новая версия — нажмите для подробностей",
|
||||
"update.available": "Доступна версия {version}",
|
||||
"update.up_to_date": "Установлена последняя версия",
|
||||
"update.prerelease": "пре-релиз",
|
||||
"update.view_release": "Подробнее",
|
||||
"update.dismiss": "Скрыть",
|
||||
"update.check_now": "Проверить обновления",
|
||||
"update.check_error": "Ошибка проверки обновлений",
|
||||
"update.last_check": "Последняя проверка",
|
||||
"update.never": "никогда",
|
||||
"update.release_notes": "Примечания к релизу",
|
||||
"update.auto_check_label": "Автоматическая проверка",
|
||||
"update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.",
|
||||
"update.enable": "Включить автопроверку",
|
||||
"update.interval_label": "Интервал проверки",
|
||||
"update.channel_label": "Канал",
|
||||
"update.channel.stable": "Стабильный",
|
||||
"update.channel.stable_desc": "Только стабильные релизы",
|
||||
"update.channel.prerelease": "Пре-релиз",
|
||||
"update.channel.prerelease_desc": "Включая альфа, бета и RC сборки",
|
||||
"update.save_settings": "Сохранить настройки",
|
||||
"update.settings_saved": "Настройки обновлений сохранены",
|
||||
"update.settings_save_error": "Не удалось сохранить настройки обновлений",
|
||||
"update.apply_now": "Обновить сейчас",
|
||||
"update.apply_confirm": "Скачать и установить версию {version}? Сервер будет перезапущен автоматически.",
|
||||
"update.apply_error": "Ошибка обновления",
|
||||
"update.applying": "Применяется обновление…",
|
||||
"update.downloading": "Загрузка…",
|
||||
"update.install_type_label": "Тип установки:",
|
||||
"update.install_type.installer": "Установщик Windows",
|
||||
"update.install_type.portable": "Портативная",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "Разработка",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "Поиск приложений…"
|
||||
|
||||
@@ -1844,6 +1844,43 @@
|
||||
"appearance.bg.scanlines": "扫描线",
|
||||
"appearance.bg.applied": "背景效果已应用",
|
||||
|
||||
"settings.tab.updates": "更新",
|
||||
"update.status_label": "更新状态",
|
||||
"update.current_version": "当前版本:",
|
||||
"update.badge_tooltip": "有新版本可用 — 点击查看详情",
|
||||
"update.available": "版本 {version} 可用",
|
||||
"update.up_to_date": "已是最新版本",
|
||||
"update.prerelease": "预发布",
|
||||
"update.view_release": "查看发布",
|
||||
"update.dismiss": "忽略",
|
||||
"update.check_now": "检查更新",
|
||||
"update.check_error": "检查更新失败",
|
||||
"update.last_check": "上次检查",
|
||||
"update.never": "从未",
|
||||
"update.release_notes": "发布说明",
|
||||
"update.auto_check_label": "自动检查设置",
|
||||
"update.auto_check_hint": "在后台定期检查新版本。",
|
||||
"update.enable": "启用自动检查",
|
||||
"update.interval_label": "检查间隔",
|
||||
"update.channel_label": "频道",
|
||||
"update.channel.stable": "稳定版",
|
||||
"update.channel.stable_desc": "仅稳定版本",
|
||||
"update.channel.prerelease": "预发布",
|
||||
"update.channel.prerelease_desc": "包括 alpha、beta 和 RC 版本",
|
||||
"update.save_settings": "保存设置",
|
||||
"update.settings_saved": "更新设置已保存",
|
||||
"update.settings_save_error": "保存更新设置失败",
|
||||
"update.apply_now": "立即更新",
|
||||
"update.apply_confirm": "下载并安装版本 {version}?服务器将自动重启。",
|
||||
"update.apply_error": "更新失败",
|
||||
"update.applying": "正在应用更新…",
|
||||
"update.downloading": "正在下载…",
|
||||
"update.install_type_label": "安装类型:",
|
||||
"update.install_type.installer": "Windows 安装程序",
|
||||
"update.install_type.portable": "便携版",
|
||||
"update.install_type.docker": "Docker",
|
||||
"update.install_type.dev": "开发环境",
|
||||
|
||||
"color_strip": {
|
||||
"notification": {
|
||||
"search_apps": "搜索通知应用…"
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="update-banner" class="update-banner" style="display:none"></div>
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@@ -206,6 +207,84 @@
|
||||
<!-- Rendered dynamically by renderAppearanceTab() -->
|
||||
</div>
|
||||
|
||||
<!-- ═══ Updates tab ═══ -->
|
||||
<div id="settings-panel-updates" class="settings-panel">
|
||||
<!-- Current version + status -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="update.status_label">Update Status</label>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<span data-i18n="update.current_version">Current version:</span>
|
||||
<strong id="update-current-version"></strong>
|
||||
</div>
|
||||
<div id="update-status-text" style="font-size:0.9rem;font-weight:600;margin-bottom:0.5rem;"></div>
|
||||
<div id="update-last-check" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:0.3rem;"></div>
|
||||
<div style="font-size:0.85rem;color:var(--text-muted);margin-bottom:0.75rem;">
|
||||
<span data-i18n="update.install_type_label">Install type:</span>
|
||||
<span id="update-install-type"></span>
|
||||
</div>
|
||||
|
||||
<!-- Download progress bar -->
|
||||
<div style="display:none;margin-bottom:0.5rem;height:4px;background:var(--border-color);border-radius:2px;overflow:hidden;">
|
||||
<div id="update-progress-bar" style="width:0%;height:100%;background:var(--primary-color);transition:width 0.3s;"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button id="update-check-btn" class="btn btn-secondary" onclick="checkForUpdates()" style="flex:1">
|
||||
<span data-i18n="update.check_now">Check for Updates</span>
|
||||
<span id="update-check-spinner" class="spinner-inline" style="display:none"></span>
|
||||
</button>
|
||||
<button id="update-apply-btn" class="btn btn-primary" onclick="applyUpdate()" style="flex:1;display:none" data-i18n="update.apply_now">Update Now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Release notes preview -->
|
||||
<div class="form-group" style="display:none">
|
||||
<div class="label-row">
|
||||
<label data-i18n="update.release_notes">Release Notes</label>
|
||||
</div>
|
||||
<pre id="update-release-notes" style="max-height:200px;overflow-y:auto;font-size:0.82rem;white-space:pre-wrap;word-break:break-word;padding:0.5rem;background:var(--bg-secondary);border-radius:var(--radius-sm);border:1px solid var(--border-color);"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label data-i18n="update.auto_check_label">Auto-Check Settings</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="update.auto_check_hint">Periodically check for new releases in the background.</small>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="update-enabled">
|
||||
<label for="update-enabled" style="margin:0" data-i18n="update.enable">Enable auto-check</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
|
||||
<div style="flex:1">
|
||||
<label for="update-interval" style="font-size:0.85rem" data-i18n="update.interval_label">Check interval</label>
|
||||
<select id="update-interval" style="width:100%">
|
||||
<option value="1">1h</option>
|
||||
<option value="6">6h</option>
|
||||
<option value="12">12h</option>
|
||||
<option value="24">24h</option>
|
||||
<option value="48">48h</option>
|
||||
<option value="168">7d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label for="update-channel" style="font-size:0.85rem" data-i18n="update.channel_label">Channel</label>
|
||||
<select id="update-channel">
|
||||
<option value="false">Stable</option>
|
||||
<option value="true">Pre-release</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveUpdateSettings()" style="width:100%" data-i18n="update.save_settings">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
Reference in New Issue
Block a user