diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 3b92833..b053e74 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -247,6 +247,7 @@ jobs: REGISTRY="${{ steps.meta.outputs.registry }}" docker build \ + --build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \ --label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \ --label "org.opencontainers.image.revision=${{ gitea.sha }}" \ -t "$REGISTRY:$TAG" \ diff --git a/TODO.md b/TODO.md index 7661f71..fb32f8b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,42 +1,26 @@ -# SQLite Migration +# Auto-Update Phase 1: Check & Notify -## Phase 1: Infrastructure -- [x] Create `storage/database.py` — SQLite connection wrapper (WAL mode, thread-safe) -- [x] Create `storage/base_sqlite_store.py` — same public API as BaseJsonStore, backed by SQLite -- [x] Create `storage/migration.py` — auto-migrate JSON files to SQLite on first run -- [x] Add `database_file` to `StorageConfig` in config.py -- [x] Update demo mode path rewriting for database_file +## Backend +- [ ] Add `packaging` to pyproject.toml dependencies +- [ ] Create `core/update/__init__.py` +- [ ] Create `core/update/release_provider.py` — ABC + data models +- [ ] Create `core/update/gitea_provider.py` — Gitea REST API implementation +- [ ] Create `core/update/version_check.py` — semver normalization + comparison +- [ ] Create `core/update/update_service.py` — background service + state machine +- [ ] Create `api/schemas/update.py` — Pydantic request/response models +- [ ] Create `api/routes/update.py` — REST endpoints +- [ ] Wire into `api/__init__.py`, `dependencies.py`, `main.py` -## Phase 2: Convert stores (one-by-one) -- [x] SyncClockStore -- [x] GradientStore -- [x] WeatherSourceStore -- [x] AutomationStore -- [x] ScenePresetStore -- [x] TemplateStore -- [x] PostprocessingTemplateStore -- [x] PatternTemplateStore -- [x] AudioTemplateStore -- [x] ColorStripProcessingTemplateStore -- [x] PictureSourceStore -- [x] AudioSourceStore -- [x] ValueSourceStore -- [x] DeviceStore -- [x] OutputTargetStore -- [x] ColorStripStore +## Frontend +- [ ] Add update banner HTML to `index.html` +- [ ] Add Updates tab to `settings.html` +- [ ] Add `has-update` CSS styles for version badge in `layout.css` +- [ ] Add update banner CSS styles in `components.css` +- [ ] Create `features/update.ts` — update check/settings/banner logic +- [ ] Wire exports in `app.ts` +- [ ] Add i18n keys to `en.json`, `ru.json`, `zh.json` -## Phase 3: Update backup/restore -- [x] Refactor backup.py to read from SQLite (export/import/backup/restore) -- [x] Keep JSON backup format identical for compatibility -- [x] Update AutoBackupEngine to read from SQLite -- [x] Add Database to dependency injection - -## Phase 4: Cleanup -- [ ] Remove individual `*_file` fields from StorageConfig (keep `database_file` only) -- [ ] Remove `atomic_write_json` usage from stores (still used by auto_backup settings) -- [ ] Remove `freeze_saves` from base_store (only `freeze_writes` needed) -- [ ] Remove BaseJsonStore (keep EntityNotFoundError — move to shared location) -- [ ] Update _save_all_stores to use _save_all() instead of _save(force=True) -- [ ] Update CLAUDE.md and server/CLAUDE.md documentation -- [ ] Remove `_json_key`/`_legacy_json_keys` references from old code -- [ ] Clean up test files to use Database fixture instead of file paths +## Verification +- [ ] Lint check: `ruff check src/ tests/ --fix` +- [ ] TypeScript check: `npx tsc --noEmit && npm run build` +- [ ] Tests pass: `py -3.13 -m pytest tests/ --no-cov -q` \ No newline at end of file diff --git a/build-dist-windows.sh b/build-dist-windows.sh index 9d1d4c2..f7e04d9 100644 --- a/build-dist-windows.sh +++ b/build-dist-windows.sh @@ -33,12 +33,16 @@ if [ -z "$VERSION" ]; then VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}" fi if [ -z "$VERSION" ]; then - VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0") + VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0") fi VERSION_CLEAN="${VERSION#v}" ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip" +# Stamp the resolved version into pyproject.toml so that +# importlib.metadata reads the correct value at runtime. +sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml" + echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ===" echo " Embedded Python: $PYTHON_VERSION" echo " Output: build/$ZIP_NAME" diff --git a/build-dist.sh b/build-dist.sh index b57702b..cb7245c 100644 --- a/build-dist.sh +++ b/build-dist.sh @@ -28,12 +28,16 @@ if [ -z "$VERSION" ]; then VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}" fi if [ -z "$VERSION" ]; then - VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0") + VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0") fi VERSION_CLEAN="${VERSION#v}" TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz" +# Stamp the resolved version into pyproject.toml so that +# importlib.metadata reads the correct value at runtime. +sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml" + echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ===" echo " Output: build/$TAR_NAME" echo "" diff --git a/contexts/auto-update-plan.md b/contexts/auto-update-plan.md new file mode 100644 index 0000000..60e7033 --- /dev/null +++ b/contexts/auto-update-plan.md @@ -0,0 +1,143 @@ +# Auto-Update Plan — Phase 1: Check & Notify + +> Created: 2026-03-25. Status: **planned, not started.** + +## Backend Architecture + +### Release Provider Abstraction + +``` +core/update/ + release_provider.py — ABC: get_releases(), get_releases_page_url() + gitea_provider.py — Gitea REST API implementation + version_check.py — normalize_version(), is_newer() using packaging.version + update_service.py — Background asyncio task + state machine +``` + +**`ReleaseProvider` interface** — two methods: +- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first) +- `get_releases_page_url() → str` — link for "view on web" + +**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`. + +**Data models:** + +```python +@dataclass(frozen=True) +class AssetInfo: + name: str # "LedGrab-v0.3.0-win-x64.zip" + size: int # bytes + download_url: str + +@dataclass(frozen=True) +class ReleaseInfo: + tag: str # "v0.3.0" + version: str # "0.3.0" + name: str # "LedGrab v0.3.0" + body: str # release notes markdown + prerelease: bool + published_at: str # ISO 8601 + assets: tuple[AssetInfo, ...] +``` + +### Version Comparison + +`version_check.py` — normalize Gitea tags to PEP 440: +- `v0.3.0-alpha.1` → `0.3.0a1` +- `v0.3.0-beta.2` → `0.3.0b2` +- `v0.3.0-rc.3` → `0.3.0rc3` + +Uses `packaging.version.Version` for comparison. + +### Update Service + +Follows the **AutoBackupEngine pattern**: +- Settings in `Database.get_setting("auto_update")` +- asyncio.Task for periodic checks +- 30s startup delay (avoid slowing boot) +- 60s debounce on manual checks + +**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE` + +No download/apply in Phase 1 — just detection and notification. + +**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release") + +**Persisted state:** `dismissed_version`, `last_check` (survives restarts) + +### API Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/v1/system/update/status` | Current state + available version | +| `POST` | `/api/v1/system/update/check` | Trigger immediate check | +| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version | +| `GET` | `/api/v1/system/update/settings` | Get settings | +| `PUT` | `/api/v1/system/update/settings` | Update settings | + +### Wiring + +- New `get_update_service()` in `dependencies.py` +- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines +- Router registered in `api/__init__.py` +- WebSocket event: `update_available` fired via `processor_manager.fire_event()` + +## Frontend + +### Version badge highlight + +The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings. + +### Notification popup + +On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed): +- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast) +- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss] +- Dismiss calls `POST /dismiss` and hides the bar for that version +- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions + +### Settings tab: "Updates" + +New 5th tab in the settings modal: +- Current version display +- "Check for updates" button + spinner +- Channel selector (stable / pre-release) via IconSelect +- Auto-check toggle + interval selector +- When update available: release name, notes preview, link to releases page + +### i18n keys + +New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels. + +## Files to Create + +| File | Purpose | +|------|---------| +| `core/update/__init__.py` | Package init | +| `core/update/release_provider.py` | Abstract provider interface + data models | +| `core/update/gitea_provider.py` | Gitea API implementation | +| `core/update/version_check.py` | Semver normalization + comparison | +| `core/update/update_service.py` | Background service + state machine | +| `api/routes/update.py` | REST endpoints | +| `api/schemas/update.py` | Pydantic request/response models | + +## Files to Modify + +| File | Change | +|------|--------| +| `api/__init__.py` | Register update router | +| `api/dependencies.py` | Add `get_update_service()` | +| `main.py` | Create & start/stop UpdateService in lifespan | +| `templates/modals/settings.html` | Add Updates tab | +| `static/js/features/settings.ts` | Update check/settings UI logic | +| `static/js/core/api.ts` | Version badge highlight on health check | +| `static/css/layout.css` | `.has-update` styles for version badge | +| `static/locales/en.json` | i18n keys | +| `static/locales/ru.json` | i18n keys | +| `static/locales/zh.json` | i18n keys | + +## Future Phases (not in scope) + +- **Phase 2**: Download & stage artifacts +- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode) +- **Phase 4**: Checksums, "What's new" dialog, update history diff --git a/server/Dockerfile b/server/Dockerfile index e207519..918baca 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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 " 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 diff --git a/server/pyproject.toml b/server/pyproject.toml index e96dd2a..8f1dfc3 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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", diff --git a/server/src/wled_controller/__init__.py b/server/src/wled_controller/__init__.py index 9d2de11..d40a6a8 100644 --- a/server/src/wled_controller/__init__.py +++ b/server/src/wled_controller/__init__.py @@ -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" diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 64338af..cfca0fb 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -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"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 0aa1e5b..a4e97b4 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -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, }) diff --git a/server/src/wled_controller/api/routes/update.py b/server/src/wled_controller/api/routes/update.py new file mode 100644 index 0000000..3a495b4 --- /dev/null +++ b/server/src/wled_controller/api/routes/update.py @@ -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, + ) diff --git a/server/src/wled_controller/api/schemas/update.py b/server/src/wled_controller/api/schemas/update.py new file mode 100644 index 0000000..ee71835 --- /dev/null +++ b/server/src/wled_controller/api/schemas/update.py @@ -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 diff --git a/server/src/wled_controller/core/update/__init__.py b/server/src/wled_controller/core/update/__init__.py new file mode 100644 index 0000000..1c6d33c --- /dev/null +++ b/server/src/wled_controller/core/update/__init__.py @@ -0,0 +1 @@ +"""Auto-update — periodic release checking and notification.""" \ No newline at end of file diff --git a/server/src/wled_controller/core/update/gitea_provider.py b/server/src/wled_controller/core/update/gitea_provider.py new file mode 100644 index 0000000..c61b3b1 --- /dev/null +++ b/server/src/wled_controller/core/update/gitea_provider.py @@ -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" diff --git a/server/src/wled_controller/core/update/install_type.py b/server/src/wled_controller/core/update/install_type.py new file mode 100644 index 0000000..38f2931 --- /dev/null +++ b/server/src/wled_controller/core/update/install_type.py @@ -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 diff --git a/server/src/wled_controller/core/update/release_provider.py b/server/src/wled_controller/core/update/release_provider.py new file mode 100644 index 0000000..ebf08f7 --- /dev/null +++ b/server/src/wled_controller/core/update/release_provider.py @@ -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.""" \ No newline at end of file diff --git a/server/src/wled_controller/core/update/update_service.py b/server/src/wled_controller/core/update/update_service.py new file mode 100644 index 0000000..63dc808 --- /dev/null +++ b/server/src/wled_controller/core/update/update_service.py @@ -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" diff --git a/server/src/wled_controller/core/update/version_check.py b/server/src/wled_controller/core/update/version_check.py new file mode 100644 index 0000000..4945f55 --- /dev/null +++ b/server/src/wled_controller/core/update/version_check.py @@ -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 diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index ea59d94..7e28934 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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() diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index f68f9e0..742fd48 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -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 { diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index b339161..630cdf7 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -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); diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts index 22a6854..3d42a78 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.ts +++ b/server/src/wled_controller/static/js/core/icon-paths.ts @@ -81,3 +81,5 @@ export const headphones = ''; export const listChecks = ''; export const circleOff = ''; +export const externalLink = ''; +export const xIcon = ''; diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 02a41a7..ed01e27 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -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); diff --git a/server/src/wled_controller/static/js/features/settings.ts b/server/src/wled_controller/static/js/features/settings.ts index 261b31a..90b2ec1 100644 --- a/server/src/wled_controller/static/js/features/settings.ts +++ b/server/src/wled_controller/static/js/features/settings.ts @@ -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 ──────────────────────────────────────────── diff --git a/server/src/wled_controller/static/js/features/update.ts b/server/src/wled_controller/static/js/features/update.ts new file mode 100644 index 0000000..3132a37 --- /dev/null +++ b/server/src/wled_controller/static/js/features/update.ts @@ -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 += ``; + } + + actions += ` + ${ICON_EXTERNAL_LINK} + `; + + actions += ``; + + banner.innerHTML = ` + + ${t('update.available').replace('{version}', versionLabel)} + + ${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 { + 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 { + 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 { + 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: 'S', + label: t('update.channel.stable'), + desc: t('update.channel.stable_desc'), + }, + { + value: 'true', + icon: 'P', + 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 { + 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 { + 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'; + } + } +} diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 5abab89..1146b5e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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…" diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 722ad8b..89f848c 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Поиск приложений…" diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 4033ac4..d74eb49 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "搜索通知应用…" diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 5c8fb60..88626b4 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -104,6 +104,7 @@ +
diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index 69da896..83cace8 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -12,6 +12,7 @@ +
+ +
+ +
+
+ +
+
+ Current version: + +
+
+
+
+ Install type: + +
+ + +
+
+
+ +
+ + +
+
+ + + + + +
+
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+