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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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"]

View File

@@ -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,
})

View 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,
)

View 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

View File

@@ -0,0 +1 @@
"""Auto-update — periodic release checking and notification."""

View 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"

View 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

View 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."""

View 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"

View 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

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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"/>';

View File

@@ -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);

View File

@@ -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 ────────────────────────────────────────────

View 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';
}
}
}

View File

@@ -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…"

View File

@@ -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": "Поиск приложений…"

View File

@@ -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": "搜索通知应用…"

View File

@@ -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">

View File

@@ -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">