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:
@@ -247,6 +247,7 @@ jobs:
|
|||||||
REGISTRY="${{ steps.meta.outputs.registry }}"
|
REGISTRY="${{ steps.meta.outputs.registry }}"
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
|
--build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \
|
||||||
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \
|
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \
|
||||||
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
||||||
-t "$REGISTRY:$TAG" \
|
-t "$REGISTRY:$TAG" \
|
||||||
|
|||||||
62
TODO.md
62
TODO.md
@@ -1,42 +1,26 @@
|
|||||||
# SQLite Migration
|
# Auto-Update Phase 1: Check & Notify
|
||||||
|
|
||||||
## Phase 1: Infrastructure
|
## Backend
|
||||||
- [x] Create `storage/database.py` — SQLite connection wrapper (WAL mode, thread-safe)
|
- [ ] Add `packaging` to pyproject.toml dependencies
|
||||||
- [x] Create `storage/base_sqlite_store.py` — same public API as BaseJsonStore, backed by SQLite
|
- [ ] Create `core/update/__init__.py`
|
||||||
- [x] Create `storage/migration.py` — auto-migrate JSON files to SQLite on first run
|
- [ ] Create `core/update/release_provider.py` — ABC + data models
|
||||||
- [x] Add `database_file` to `StorageConfig` in config.py
|
- [ ] Create `core/update/gitea_provider.py` — Gitea REST API implementation
|
||||||
- [x] Update demo mode path rewriting for database_file
|
- [ ] 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)
|
## Frontend
|
||||||
- [x] SyncClockStore
|
- [ ] Add update banner HTML to `index.html`
|
||||||
- [x] GradientStore
|
- [ ] Add Updates tab to `settings.html`
|
||||||
- [x] WeatherSourceStore
|
- [ ] Add `has-update` CSS styles for version badge in `layout.css`
|
||||||
- [x] AutomationStore
|
- [ ] Add update banner CSS styles in `components.css`
|
||||||
- [x] ScenePresetStore
|
- [ ] Create `features/update.ts` — update check/settings/banner logic
|
||||||
- [x] TemplateStore
|
- [ ] Wire exports in `app.ts`
|
||||||
- [x] PostprocessingTemplateStore
|
- [ ] Add i18n keys to `en.json`, `ru.json`, `zh.json`
|
||||||
- [x] PatternTemplateStore
|
|
||||||
- [x] AudioTemplateStore
|
|
||||||
- [x] ColorStripProcessingTemplateStore
|
|
||||||
- [x] PictureSourceStore
|
|
||||||
- [x] AudioSourceStore
|
|
||||||
- [x] ValueSourceStore
|
|
||||||
- [x] DeviceStore
|
|
||||||
- [x] OutputTargetStore
|
|
||||||
- [x] ColorStripStore
|
|
||||||
|
|
||||||
## Phase 3: Update backup/restore
|
## Verification
|
||||||
- [x] Refactor backup.py to read from SQLite (export/import/backup/restore)
|
- [ ] Lint check: `ruff check src/ tests/ --fix`
|
||||||
- [x] Keep JSON backup format identical for compatibility
|
- [ ] TypeScript check: `npx tsc --noEmit && npm run build`
|
||||||
- [x] Update AutoBackupEngine to read from SQLite
|
- [ ] Tests pass: `py -3.13 -m pytest tests/ --no-cov -q`
|
||||||
- [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
|
|
||||||
@@ -33,12 +33,16 @@ if [ -z "$VERSION" ]; then
|
|||||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||||
fi
|
fi
|
||||||
if [ -z "$VERSION" ]; then
|
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
|
fi
|
||||||
|
|
||||||
VERSION_CLEAN="${VERSION#v}"
|
VERSION_CLEAN="${VERSION#v}"
|
||||||
ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip"
|
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 "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ==="
|
||||||
echo " Embedded Python: $PYTHON_VERSION"
|
echo " Embedded Python: $PYTHON_VERSION"
|
||||||
echo " Output: build/$ZIP_NAME"
|
echo " Output: build/$ZIP_NAME"
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ if [ -z "$VERSION" ]; then
|
|||||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||||
fi
|
fi
|
||||||
if [ -z "$VERSION" ]; then
|
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
|
fi
|
||||||
|
|
||||||
VERSION_CLEAN="${VERSION#v}"
|
VERSION_CLEAN="${VERSION#v}"
|
||||||
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
|
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 "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
|
||||||
echo " Output: build/$TAR_NAME"
|
echo " Output: build/$TAR_NAME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
143
contexts/auto-update-plan.md
Normal file
143
contexts/auto-update-plan.md
Normal file
@@ -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
|
||||||
@@ -10,10 +10,12 @@ RUN npm run build
|
|||||||
## Stage 2: Python application
|
## Stage 2: Python application
|
||||||
FROM python:3.11.11-slim AS runtime
|
FROM python:3.11.11-slim AS runtime
|
||||||
|
|
||||||
|
ARG APP_VERSION=0.0.0
|
||||||
|
|
||||||
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
LABEL maintainer="Alexei Dolgolyov <dolgolyov.alexei@gmail.com>"
|
||||||
LABEL org.opencontainers.image.title="LED Grab"
|
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.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.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.source="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
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.
|
# Copy pyproject.toml with a minimal package stub so pip can resolve deps.
|
||||||
# The real source is copied afterward, keeping the dep layer cached.
|
# The real source is copied afterward, keeping the dep layer cached.
|
||||||
COPY pyproject.toml .
|
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]" \
|
&& pip install --no-cache-dir ".[notifications]" \
|
||||||
&& rm -rf src/wled_controller
|
&& rm -rf src/wled_controller
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.32.0",
|
"uvicorn[standard]>=0.32.0",
|
||||||
"httpx>=0.27.2",
|
"httpx>=0.27.2",
|
||||||
|
"packaging>=23.0",
|
||||||
"mss>=9.0.2",
|
"mss>=9.0.2",
|
||||||
"Pillow>=10.4.0",
|
"Pillow>=10.4.0",
|
||||||
"numpy>=2.1.3",
|
"numpy>=2.1.3",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
"""LED Grab - Ambient lighting based on screen content."""
|
"""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"
|
__author__ = "Alexei Dolgolyov"
|
||||||
__email__ = "dolgolyov.alexei@gmail.com"
|
__email__ = "dolgolyov.alexei@gmail.com"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .routes.sync_clocks import router as sync_clocks_router
|
|||||||
from .routes.color_strip_processing import router as cspt_router
|
from .routes.color_strip_processing import router as cspt_router
|
||||||
from .routes.gradients import router as gradients_router
|
from .routes.gradients import router as gradients_router
|
||||||
from .routes.weather_sources import router as weather_sources_router
|
from .routes.weather_sources import router as weather_sources_router
|
||||||
|
from .routes.update import router as update_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -50,5 +51,6 @@ router.include_router(sync_clocks_router)
|
|||||||
router.include_router(cspt_router)
|
router.include_router(cspt_router)
|
||||||
router.include_router(gradients_router)
|
router.include_router(gradients_router)
|
||||||
router.include_router(weather_sources_router)
|
router.include_router(weather_sources_router)
|
||||||
|
router.include_router(update_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from wled_controller.core.automations.automation_engine import AutomationEngine
|
|||||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
|
from wled_controller.core.update.update_service import UpdateService
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -134,6 +135,10 @@ def get_database() -> Database:
|
|||||||
return _get("database", "Database")
|
return _get("database", "Database")
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_service() -> UpdateService:
|
||||||
|
return _get("update_service", "Update service")
|
||||||
|
|
||||||
|
|
||||||
# ── Event helper ────────────────────────────────────────────────────────
|
# ── Event helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +186,7 @@ def init_dependencies(
|
|||||||
gradient_store: GradientStore | None = None,
|
gradient_store: GradientStore | None = None,
|
||||||
weather_source_store: WeatherSourceStore | None = None,
|
weather_source_store: WeatherSourceStore | None = None,
|
||||||
weather_manager: WeatherManager | None = None,
|
weather_manager: WeatherManager | None = None,
|
||||||
|
update_service: UpdateService | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update({
|
_deps.update({
|
||||||
@@ -206,4 +212,5 @@ def init_dependencies(
|
|||||||
"gradient_store": gradient_store,
|
"gradient_store": gradient_store,
|
||||||
"weather_source_store": weather_source_store,
|
"weather_source_store": weather_source_store,
|
||||||
"weather_manager": weather_manager,
|
"weather_manager": weather_manager,
|
||||||
|
"update_service": update_service,
|
||||||
})
|
})
|
||||||
|
|||||||
81
server/src/wled_controller/api/routes/update.py
Normal file
81
server/src/wled_controller/api/routes/update.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""API routes for the auto-update system."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from wled_controller.api.dependencies import get_update_service
|
||||||
|
from wled_controller.api.schemas.update import (
|
||||||
|
DismissRequest,
|
||||||
|
UpdateSettingsRequest,
|
||||||
|
UpdateSettingsResponse,
|
||||||
|
UpdateStatusResponse,
|
||||||
|
)
|
||||||
|
from wled_controller.core.update.update_service import UpdateService
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=UpdateStatusResponse)
|
||||||
|
async def get_update_status(
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
return service.get_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check", response_model=UpdateStatusResponse)
|
||||||
|
async def check_for_updates(
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
return await service.check_now()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dismiss")
|
||||||
|
async def dismiss_update(
|
||||||
|
body: DismissRequest,
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
service.dismiss(body.version)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply")
|
||||||
|
async def apply_update(
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
"""Download (if needed) and apply the available update."""
|
||||||
|
status = service.get_status()
|
||||||
|
if not status["has_update"]:
|
||||||
|
return JSONResponse(status_code=400, content={"detail": "No update available"})
|
||||||
|
if not status["can_auto_update"]:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": f"Auto-update not supported for install type: {status['install_type']}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.apply_update()
|
||||||
|
return {"ok": True, "message": "Update applied, server shutting down"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to apply update: %s", exc, exc_info=True)
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=UpdateSettingsResponse)
|
||||||
|
async def get_update_settings(
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
return service.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=UpdateSettingsResponse)
|
||||||
|
async def update_update_settings(
|
||||||
|
body: UpdateSettingsRequest,
|
||||||
|
service: UpdateService = Depends(get_update_service),
|
||||||
|
):
|
||||||
|
return await service.update_settings(
|
||||||
|
enabled=body.enabled,
|
||||||
|
check_interval_hours=body.check_interval_hours,
|
||||||
|
include_prerelease=body.include_prerelease,
|
||||||
|
)
|
||||||
44
server/src/wled_controller/api/schemas/update.py
Normal file
44
server/src/wled_controller/api/schemas/update.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Pydantic schemas for the update API."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateReleaseInfo(BaseModel):
|
||||||
|
version: str
|
||||||
|
tag: str
|
||||||
|
name: str
|
||||||
|
body: str
|
||||||
|
prerelease: bool
|
||||||
|
published_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateStatusResponse(BaseModel):
|
||||||
|
current_version: str
|
||||||
|
has_update: bool
|
||||||
|
checking: bool
|
||||||
|
last_check: float | None
|
||||||
|
last_error: str | None
|
||||||
|
releases_url: str
|
||||||
|
install_type: str
|
||||||
|
can_auto_update: bool
|
||||||
|
downloading: bool
|
||||||
|
download_progress: float
|
||||||
|
applying: bool
|
||||||
|
release: UpdateReleaseInfo | None
|
||||||
|
dismissed_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettingsResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
check_interval_hours: float
|
||||||
|
include_prerelease: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSettingsRequest(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
check_interval_hours: float = Field(ge=0.5, le=168)
|
||||||
|
include_prerelease: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DismissRequest(BaseModel):
|
||||||
|
version: str
|
||||||
1
server/src/wled_controller/core/update/__init__.py
Normal file
1
server/src/wled_controller/core/update/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Auto-update — periodic release checking and notification."""
|
||||||
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
54
server/src/wled_controller/core/update/gitea_provider.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Gitea release provider — fetches releases from a Gitea instance."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaReleaseProvider(ReleaseProvider):
|
||||||
|
"""Fetch releases from a Gitea repository via its REST API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, repo: str, token: str = "") -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._repo = repo
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||||
|
url = f"{self._base_url}/api/v1/repos/{self._repo}/releases"
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if self._token:
|
||||||
|
headers["Authorization"] = f"token {self._token}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, params={"limit": limit}, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
releases: list[ReleaseInfo] = []
|
||||||
|
for item in data:
|
||||||
|
tag = item.get("tag_name", "")
|
||||||
|
version = tag.lstrip("v")
|
||||||
|
assets = tuple(
|
||||||
|
AssetInfo(
|
||||||
|
name=a["name"],
|
||||||
|
size=a.get("size", 0),
|
||||||
|
download_url=a["browser_download_url"],
|
||||||
|
)
|
||||||
|
for a in item.get("assets", [])
|
||||||
|
)
|
||||||
|
releases.append(ReleaseInfo(
|
||||||
|
tag=tag,
|
||||||
|
version=version,
|
||||||
|
name=item.get("name", tag),
|
||||||
|
body=item.get("body", ""),
|
||||||
|
prerelease=item.get("prerelease", False),
|
||||||
|
published_at=item.get("published_at", ""),
|
||||||
|
assets=assets,
|
||||||
|
))
|
||||||
|
return releases
|
||||||
|
|
||||||
|
def get_releases_page_url(self) -> str:
|
||||||
|
return f"{self._base_url}/{self._repo}/releases"
|
||||||
73
server/src/wled_controller/core/update/install_type.py
Normal file
73
server/src/wled_controller/core/update/install_type.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Detect how the application was installed.
|
||||||
|
|
||||||
|
The install type determines which update strategy is available:
|
||||||
|
- installer: NSIS `.exe` installed to AppData — can run new installer silently
|
||||||
|
- portable: Extracted ZIP with embedded Python — can replace app/ + python/ dirs
|
||||||
|
- docker: Running inside a Docker container — no auto-update, show instructions
|
||||||
|
- dev: Running from source (pip install -e) — no auto-update, link to releases
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallType(str, Enum):
|
||||||
|
INSTALLER = "installer"
|
||||||
|
PORTABLE = "portable"
|
||||||
|
DOCKER = "docker"
|
||||||
|
DEV = "dev"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_install_type() -> InstallType:
|
||||||
|
"""Detect the current install type once at startup."""
|
||||||
|
# Docker: /.dockerenv file or cgroup hints
|
||||||
|
if Path("/.dockerenv").exists():
|
||||||
|
logger.info("Install type: docker")
|
||||||
|
return InstallType.DOCKER
|
||||||
|
|
||||||
|
# Windows installed/portable: look for embedded Python dir
|
||||||
|
app_root = Path.cwd()
|
||||||
|
has_uninstaller = (app_root / "uninstall.exe").exists()
|
||||||
|
has_embedded_python = (app_root / "python" / "python.exe").exists()
|
||||||
|
|
||||||
|
if has_uninstaller:
|
||||||
|
logger.info("Install type: installer (uninstall.exe found at %s)", app_root)
|
||||||
|
return InstallType.INSTALLER
|
||||||
|
|
||||||
|
if has_embedded_python:
|
||||||
|
logger.info("Install type: portable (embedded python/ found at %s)", app_root)
|
||||||
|
return InstallType.PORTABLE
|
||||||
|
|
||||||
|
# Linux portable: look for venv/ + run.sh
|
||||||
|
if (app_root / "venv").is_dir() and (app_root / "run.sh").exists():
|
||||||
|
logger.info("Install type: portable (Linux venv layout at %s)", app_root)
|
||||||
|
return InstallType.PORTABLE
|
||||||
|
|
||||||
|
logger.info("Install type: dev (no distribution markers found)")
|
||||||
|
return InstallType.DEV
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform_asset_pattern(install_type: InstallType) -> str | None:
|
||||||
|
"""Return a substring that the matching release asset name must contain.
|
||||||
|
|
||||||
|
Returns None if auto-update is not supported for this install type.
|
||||||
|
"""
|
||||||
|
if install_type == InstallType.DOCKER:
|
||||||
|
return None
|
||||||
|
if install_type == InstallType.DEV:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if install_type == InstallType.INSTALLER:
|
||||||
|
return "-setup.exe"
|
||||||
|
return "-win-x64.zip"
|
||||||
|
|
||||||
|
if sys.platform == "linux":
|
||||||
|
return "-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
return None
|
||||||
41
server/src/wled_controller/core/update/release_provider.py
Normal file
41
server/src/wled_controller/core/update/release_provider.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Abstract release provider and data models."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AssetInfo:
|
||||||
|
"""A single downloadable asset attached to a release."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
size: int
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseInfo:
|
||||||
|
"""A single release from the hosting platform."""
|
||||||
|
|
||||||
|
tag: str
|
||||||
|
version: str
|
||||||
|
name: str
|
||||||
|
body: str
|
||||||
|
prerelease: bool
|
||||||
|
published_at: str
|
||||||
|
assets: tuple[AssetInfo, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseProvider(ABC):
|
||||||
|
"""Platform-agnostic interface for querying releases.
|
||||||
|
|
||||||
|
Implement this for Gitea, GitHub, GitLab, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_releases(self, limit: int = 10) -> list[ReleaseInfo]:
|
||||||
|
"""Fetch recent releases, newest first."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_releases_page_url(self) -> str:
|
||||||
|
"""Return the user-facing URL of the releases page."""
|
||||||
518
server/src/wled_controller/core/update/update_service.py
Normal file
518
server/src/wled_controller/core/update/update_service.py
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
"""Background service that periodically checks for new releases."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from wled_controller import __version__
|
||||||
|
from wled_controller.core.update.install_type import InstallType, detect_install_type, get_platform_asset_pattern
|
||||||
|
from wled_controller.core.update.release_provider import AssetInfo, ReleaseInfo, ReleaseProvider
|
||||||
|
from wled_controller.core.update.version_check import is_newer, normalize_version
|
||||||
|
from wled_controller.storage.database import Database
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||||
|
"enabled": True,
|
||||||
|
"check_interval_hours": 24.0,
|
||||||
|
"include_prerelease": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
_STARTUP_DELAY_S = 30
|
||||||
|
_MANUAL_CHECK_DEBOUNCE_S = 60
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateService:
|
||||||
|
"""Periodically polls a ReleaseProvider and fires WebSocket events."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: ReleaseProvider,
|
||||||
|
db: Database,
|
||||||
|
fire_event: Any = None,
|
||||||
|
update_dir: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
self._db = db
|
||||||
|
self._fire_event = fire_event
|
||||||
|
|
||||||
|
self._settings = self._load_settings()
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Install type (detected once)
|
||||||
|
self._install_type = detect_install_type()
|
||||||
|
self._asset_pattern = get_platform_asset_pattern(self._install_type)
|
||||||
|
|
||||||
|
# Download directory
|
||||||
|
self._update_dir = update_dir or Path("data/updates")
|
||||||
|
self._update_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
self._available_release: ReleaseInfo | None = None
|
||||||
|
self._last_check: float = 0.0
|
||||||
|
self._checking = False
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
# Download/apply state
|
||||||
|
self._download_progress: float = 0.0 # 0..1
|
||||||
|
self._downloading = False
|
||||||
|
self._downloaded_file: Path | None = None
|
||||||
|
self._applying = False
|
||||||
|
|
||||||
|
# Load persisted state
|
||||||
|
persisted = self._db.get_setting("update_state") or {}
|
||||||
|
self._dismissed_version: str = persisted.get("dismissed_version", "")
|
||||||
|
|
||||||
|
# ── Settings persistence ───────────────────────────────────
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
data = self._db.get_setting("auto_update")
|
||||||
|
if data:
|
||||||
|
return {**DEFAULT_SETTINGS, **data}
|
||||||
|
return dict(DEFAULT_SETTINGS)
|
||||||
|
|
||||||
|
def _save_settings(self) -> None:
|
||||||
|
self._db.set_setting("auto_update", {
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"check_interval_hours": self._settings["check_interval_hours"],
|
||||||
|
"include_prerelease": self._settings["include_prerelease"],
|
||||||
|
})
|
||||||
|
|
||||||
|
def _save_state(self) -> None:
|
||||||
|
self._db.set_setting("update_state", {
|
||||||
|
"dismissed_version": self._dismissed_version,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._settings["enabled"]:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
"Update checker started (every %.1fh, prerelease=%s, install=%s)",
|
||||||
|
self._settings["check_interval_hours"],
|
||||||
|
self._settings["include_prerelease"],
|
||||||
|
self._install_type.value,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Update checker initialized (disabled, install=%s)", self._install_type.value)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Update checker stopped")
|
||||||
|
|
||||||
|
def _start_loop(self) -> None:
|
||||||
|
self._cancel_loop()
|
||||||
|
self._task = asyncio.create_task(self._check_loop())
|
||||||
|
|
||||||
|
def _cancel_loop(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _check_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(_STARTUP_DELAY_S)
|
||||||
|
await self._perform_check()
|
||||||
|
|
||||||
|
interval_s = self._settings["check_interval_hours"] * 3600
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_s)
|
||||||
|
try:
|
||||||
|
await self._perform_check()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Update check failed: %s", exc, exc_info=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Core check logic ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _perform_check(self) -> None:
|
||||||
|
self._checking = True
|
||||||
|
self._last_error = None
|
||||||
|
try:
|
||||||
|
releases = await self._provider.get_releases(limit=10)
|
||||||
|
best = self._find_best_release(releases)
|
||||||
|
self._available_release = best
|
||||||
|
self._last_check = time.time()
|
||||||
|
|
||||||
|
if best and self._fire_event:
|
||||||
|
self._fire_event({
|
||||||
|
"type": "update_available",
|
||||||
|
"version": best.version,
|
||||||
|
"tag": best.tag,
|
||||||
|
"name": best.name,
|
||||||
|
"prerelease": best.prerelease,
|
||||||
|
"dismissed": best.version == self._dismissed_version,
|
||||||
|
"can_auto_update": self._can_auto_update(best),
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
"Update check complete — %s",
|
||||||
|
f"v{best.version} available" if best else "up to date",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._checking = False
|
||||||
|
|
||||||
|
def _find_best_release(self, releases: list[ReleaseInfo]) -> ReleaseInfo | None:
|
||||||
|
"""Find the newest release that is newer than the current version."""
|
||||||
|
include_pre = self._settings["include_prerelease"]
|
||||||
|
for release in releases:
|
||||||
|
if release.prerelease and not include_pre:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
normalize_version(release.version)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if is_newer(release.version, __version__):
|
||||||
|
return release
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_asset(self, release: ReleaseInfo) -> AssetInfo | None:
|
||||||
|
"""Find the matching asset for this platform + install type."""
|
||||||
|
if not self._asset_pattern:
|
||||||
|
return None
|
||||||
|
for asset in release.assets:
|
||||||
|
if self._asset_pattern in asset.name:
|
||||||
|
return asset
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _can_auto_update(self, release: ReleaseInfo) -> bool:
|
||||||
|
"""Check if auto-update is possible for the given release."""
|
||||||
|
return self._find_asset(release) is not None
|
||||||
|
|
||||||
|
# ── Download ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def download_update(self) -> Path:
|
||||||
|
"""Download the update asset. Returns path to downloaded file."""
|
||||||
|
release = self._available_release
|
||||||
|
if not release:
|
||||||
|
raise RuntimeError("No update available")
|
||||||
|
|
||||||
|
asset = self._find_asset(release)
|
||||||
|
if not asset:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No matching asset for {self._install_type.value} "
|
||||||
|
f"on {sys.platform}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dest = self._update_dir / asset.name
|
||||||
|
# Skip re-download if file exists and size matches
|
||||||
|
if dest.exists() and dest.stat().st_size == asset.size:
|
||||||
|
logger.info("Update already downloaded: %s", dest.name)
|
||||||
|
self._downloaded_file = dest
|
||||||
|
self._download_progress = 1.0
|
||||||
|
return dest
|
||||||
|
|
||||||
|
self._downloading = True
|
||||||
|
self._download_progress = 0.0
|
||||||
|
try:
|
||||||
|
await self._stream_download(asset.download_url, dest, asset.size)
|
||||||
|
self._downloaded_file = dest
|
||||||
|
logger.info("Downloaded update: %s (%d bytes)", dest.name, dest.stat().st_size)
|
||||||
|
return dest
|
||||||
|
except Exception:
|
||||||
|
# Clean up partial download
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink()
|
||||||
|
self._downloaded_file = None
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._downloading = False
|
||||||
|
|
||||||
|
async def _stream_download(self, url: str, dest: Path, total_size: int) -> None:
|
||||||
|
"""Stream-download a file, updating progress as we go."""
|
||||||
|
tmp = dest.with_suffix(dest.suffix + ".tmp")
|
||||||
|
received = 0
|
||||||
|
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", url) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
received += len(chunk)
|
||||||
|
if total_size > 0:
|
||||||
|
self._download_progress = received / total_size
|
||||||
|
if self._fire_event:
|
||||||
|
self._fire_event({
|
||||||
|
"type": "update_download_progress",
|
||||||
|
"progress": round(self._download_progress, 3),
|
||||||
|
})
|
||||||
|
# Atomic rename
|
||||||
|
tmp.replace(dest)
|
||||||
|
self._download_progress = 1.0
|
||||||
|
|
||||||
|
# ── Apply ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def apply_update(self) -> None:
|
||||||
|
"""Download (if needed) and apply the update, then shut down."""
|
||||||
|
if self._applying:
|
||||||
|
raise RuntimeError("Update already in progress")
|
||||||
|
self._applying = True
|
||||||
|
try:
|
||||||
|
if not self._downloaded_file or not self._downloaded_file.exists():
|
||||||
|
await self.download_update()
|
||||||
|
|
||||||
|
assert self._downloaded_file is not None
|
||||||
|
file_path = self._downloaded_file
|
||||||
|
|
||||||
|
if self._install_type == InstallType.INSTALLER:
|
||||||
|
await self._apply_installer(file_path)
|
||||||
|
elif self._install_type == InstallType.PORTABLE:
|
||||||
|
if file_path.suffix == ".zip":
|
||||||
|
await self._apply_portable_zip(file_path)
|
||||||
|
elif file_path.name.endswith(".tar.gz"):
|
||||||
|
await self._apply_portable_tarball(file_path)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unknown portable format: {file_path.name}")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Auto-update not supported for install type: {self._install_type.value}")
|
||||||
|
finally:
|
||||||
|
self._applying = False
|
||||||
|
|
||||||
|
async def _apply_installer(self, exe_path: Path) -> None:
|
||||||
|
"""Launch the NSIS installer silently and shut down."""
|
||||||
|
install_dir = str(Path.cwd())
|
||||||
|
logger.info("Launching silent installer: %s /S /D=%s", exe_path, install_dir)
|
||||||
|
|
||||||
|
# Fire event so frontend shows restart overlay
|
||||||
|
if self._fire_event:
|
||||||
|
self._fire_event({"type": "server_restarting"})
|
||||||
|
|
||||||
|
# Launch installer detached — it will wait for python.exe to exit,
|
||||||
|
# then install and the VBS launcher / service will restart the app.
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(exe_path), "/S", f"/D={install_dir}"],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give the installer a moment to start, then shut down
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
from wled_controller.server_ref import request_shutdown
|
||||||
|
request_shutdown()
|
||||||
|
|
||||||
|
async def _apply_portable_zip(self, zip_path: Path) -> None:
|
||||||
|
"""Extract ZIP over the current installation, then shut down."""
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
app_root = Path.cwd()
|
||||||
|
staging = self._update_dir / "_staging"
|
||||||
|
|
||||||
|
logger.info("Extracting portable update: %s", zip_path.name)
|
||||||
|
|
||||||
|
# Extract to staging dir in a thread (I/O bound)
|
||||||
|
def _extract():
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zf.extractall(staging)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_extract)
|
||||||
|
|
||||||
|
# The ZIP contains a top-level LedGrab/ dir — find it
|
||||||
|
inner = _find_single_child_dir(staging)
|
||||||
|
|
||||||
|
# Write a post-update script that swaps the dirs after shutdown.
|
||||||
|
# On Windows, python.exe locks files, so we need a bat script
|
||||||
|
# that waits for the process to exit, then does the swap.
|
||||||
|
script = self._update_dir / "_apply_update.bat"
|
||||||
|
script.write_text(
|
||||||
|
_build_swap_script(inner, app_root, ["app", "python", "scripts"]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Launching post-update script and shutting down")
|
||||||
|
if self._fire_event:
|
||||||
|
self._fire_event({"type": "server_restarting"})
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
["cmd.exe", "/c", str(script)],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
from wled_controller.server_ref import request_shutdown
|
||||||
|
request_shutdown()
|
||||||
|
|
||||||
|
async def _apply_portable_tarball(self, tar_path: Path) -> None:
|
||||||
|
"""Extract tarball over the current installation, then shut down."""
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
app_root = Path.cwd()
|
||||||
|
staging = self._update_dir / "_staging"
|
||||||
|
|
||||||
|
logger.info("Extracting portable update: %s", tar_path.name)
|
||||||
|
|
||||||
|
def _extract():
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
with tarfile.open(tar_path, "r:gz") as tf:
|
||||||
|
tf.extractall(staging, filter="data")
|
||||||
|
|
||||||
|
await asyncio.to_thread(_extract)
|
||||||
|
|
||||||
|
inner = _find_single_child_dir(staging)
|
||||||
|
|
||||||
|
# On Linux, write a shell script that replaces dirs after shutdown
|
||||||
|
script = self._update_dir / "_apply_update.sh"
|
||||||
|
dirs_to_swap = ["app", "venv"]
|
||||||
|
lines = [
|
||||||
|
"#!/bin/bash",
|
||||||
|
"# Auto-generated update script — replaces app dirs and restarts",
|
||||||
|
f'APP_ROOT="{app_root}"',
|
||||||
|
f'STAGING="{inner}"',
|
||||||
|
"sleep 3 # wait for server to exit",
|
||||||
|
]
|
||||||
|
for d in dirs_to_swap:
|
||||||
|
lines.append(f'[ -d "$STAGING/{d}" ] && rm -rf "$APP_ROOT/{d}" && mv "$STAGING/{d}" "$APP_ROOT/{d}"')
|
||||||
|
# Copy scripts/ and run.sh if present
|
||||||
|
lines.append('[ -f "$STAGING/run.sh" ] && cp "$STAGING/run.sh" "$APP_ROOT/run.sh"')
|
||||||
|
lines.append(f'rm -rf "{staging}"')
|
||||||
|
lines.append(f'rm -f "{script}"')
|
||||||
|
lines.append('echo "Update applied. Restarting..."')
|
||||||
|
lines.append('cd "$APP_ROOT" && exec ./run.sh')
|
||||||
|
script.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
os.chmod(script, 0o755)
|
||||||
|
|
||||||
|
logger.info("Launching post-update script and shutting down")
|
||||||
|
if self._fire_event:
|
||||||
|
self._fire_event({"type": "server_restarting"})
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
["/bin/bash", str(script)],
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
from wled_controller.server_ref import request_shutdown
|
||||||
|
request_shutdown()
|
||||||
|
|
||||||
|
# ── Public API (called from routes) ────────────────────────
|
||||||
|
|
||||||
|
async def check_now(self) -> dict:
|
||||||
|
"""Trigger an immediate check (with debounce)."""
|
||||||
|
elapsed = time.time() - self._last_check
|
||||||
|
if elapsed < _MANUAL_CHECK_DEBOUNCE_S and self._available_release is not None:
|
||||||
|
return self.get_status()
|
||||||
|
await self._perform_check()
|
||||||
|
return self.get_status()
|
||||||
|
|
||||||
|
def dismiss(self, version: str) -> None:
|
||||||
|
"""Dismiss the notification for *version*."""
|
||||||
|
self._dismissed_version = version
|
||||||
|
self._save_state()
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
rel = self._available_release
|
||||||
|
can_auto = rel is not None and self._can_auto_update(rel)
|
||||||
|
return {
|
||||||
|
"current_version": __version__,
|
||||||
|
"has_update": rel is not None,
|
||||||
|
"checking": self._checking,
|
||||||
|
"last_check": self._last_check if self._last_check else None,
|
||||||
|
"last_error": self._last_error,
|
||||||
|
"releases_url": self._provider.get_releases_page_url(),
|
||||||
|
"install_type": self._install_type.value,
|
||||||
|
"can_auto_update": can_auto,
|
||||||
|
"downloading": self._downloading,
|
||||||
|
"download_progress": round(self._download_progress, 3),
|
||||||
|
"applying": self._applying,
|
||||||
|
"release": {
|
||||||
|
"version": rel.version,
|
||||||
|
"tag": rel.tag,
|
||||||
|
"name": rel.name,
|
||||||
|
"body": rel.body,
|
||||||
|
"prerelease": rel.prerelease,
|
||||||
|
"published_at": rel.published_at,
|
||||||
|
} if rel else None,
|
||||||
|
"dismissed_version": self._dismissed_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_settings(self) -> dict:
|
||||||
|
return {
|
||||||
|
"enabled": self._settings["enabled"],
|
||||||
|
"check_interval_hours": self._settings["check_interval_hours"],
|
||||||
|
"include_prerelease": self._settings["include_prerelease"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update_settings(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
check_interval_hours: float,
|
||||||
|
include_prerelease: bool,
|
||||||
|
) -> dict:
|
||||||
|
self._settings["enabled"] = enabled
|
||||||
|
self._settings["check_interval_hours"] = check_interval_hours
|
||||||
|
self._settings["include_prerelease"] = include_prerelease
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
self._start_loop()
|
||||||
|
logger.info(
|
||||||
|
"Update checker enabled (every %.1fh, prerelease=%s)",
|
||||||
|
check_interval_hours,
|
||||||
|
include_prerelease,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._cancel_loop()
|
||||||
|
logger.info("Update checker disabled")
|
||||||
|
|
||||||
|
return self.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _find_single_child_dir(parent: Path) -> Path:
|
||||||
|
"""Return the single subdirectory inside *parent* (e.g. LedGrab/)."""
|
||||||
|
children = [c for c in parent.iterdir() if c.is_dir()]
|
||||||
|
if len(children) == 1:
|
||||||
|
return children[0]
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def _build_swap_script(staging: Path, app_root: Path, dirs: list[str]) -> str:
|
||||||
|
"""Build a Windows batch script that replaces dirs after the server exits."""
|
||||||
|
lines = [
|
||||||
|
"@echo off",
|
||||||
|
"REM Auto-generated update script — replaces app dirs and restarts",
|
||||||
|
"echo Waiting for server to exit...",
|
||||||
|
"timeout /t 5 /nobreak >nul",
|
||||||
|
]
|
||||||
|
for d in dirs:
|
||||||
|
src = staging / d
|
||||||
|
dst = app_root / d
|
||||||
|
lines.append(f'if exist "{src}" (')
|
||||||
|
lines.append(f' rmdir /s /q "{dst}" 2>nul')
|
||||||
|
lines.append(f' move /y "{src}" "{dst}"')
|
||||||
|
lines.append(")")
|
||||||
|
# Copy LedGrab.bat if present
|
||||||
|
bat = staging / "LedGrab.bat"
|
||||||
|
lines.append(f'if exist "{bat}" copy /y "{bat}" "{app_root / "LedGrab.bat"}"')
|
||||||
|
# Cleanup
|
||||||
|
lines.append(f'rmdir /s /q "{staging.parent}" 2>nul')
|
||||||
|
lines.append('del /f /q "%~f0" 2>nul')
|
||||||
|
lines.append('echo Update complete. Restarting...')
|
||||||
|
# Restart via VBS launcher or bat
|
||||||
|
vbs = app_root / "scripts" / "start-hidden.vbs"
|
||||||
|
bat_launcher = app_root / "LedGrab.bat"
|
||||||
|
lines.append(f'if exist "{vbs}" (')
|
||||||
|
lines.append(f' start "" wscript.exe "{vbs}"')
|
||||||
|
lines.append(") else (")
|
||||||
|
lines.append(f' start "" "{bat_launcher}"')
|
||||||
|
lines.append(")")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
45
server/src/wled_controller/core/update/version_check.py
Normal file
45
server/src/wled_controller/core/update/version_check.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Version comparison utilities.
|
||||||
|
|
||||||
|
Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
|
||||||
|
so that ``packaging.version.Version`` can compare them correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
|
|
||||||
|
_PRE_MAP = {
|
||||||
|
"alpha": "a",
|
||||||
|
"beta": "b",
|
||||||
|
"rc": "rc",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PRE_PATTERN = re.compile(
|
||||||
|
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_version(raw: str) -> Version:
|
||||||
|
"""Convert a tag like ``v0.3.0-alpha.1`` to a PEP 440 ``Version``.
|
||||||
|
|
||||||
|
Raises ``InvalidVersion`` if the string cannot be parsed.
|
||||||
|
"""
|
||||||
|
cleaned = raw.lstrip("v").strip()
|
||||||
|
m = _PRE_PATTERN.match(cleaned)
|
||||||
|
if m:
|
||||||
|
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
|
||||||
|
pep_label = _PRE_MAP.get(pre_label, pre_label)
|
||||||
|
cleaned = f"{base}{pep_label}{pre_num}"
|
||||||
|
return Version(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def is_newer(candidate: str, current: str) -> bool:
|
||||||
|
"""Return True if *candidate* is strictly newer than *current*.
|
||||||
|
|
||||||
|
Returns False if either version string is unparseable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return normalize_version(candidate) > normalize_version(current)
|
||||||
|
except InvalidVersion:
|
||||||
|
return False
|
||||||
@@ -41,6 +41,8 @@ from wled_controller.core.mqtt.mqtt_service import MQTTService
|
|||||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
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.storage.database import Database
|
||||||
from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
|
from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler
|
||||||
|
|
||||||
@@ -167,6 +169,18 @@ async def lifespan(app: FastAPI):
|
|||||||
db=db,
|
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
|
# Initialize API dependencies
|
||||||
init_dependencies(
|
init_dependencies(
|
||||||
device_store, template_store, processor_manager,
|
device_store, template_store, processor_manager,
|
||||||
@@ -189,6 +203,7 @@ async def lifespan(app: FastAPI):
|
|||||||
gradient_store=gradient_store,
|
gradient_store=gradient_store,
|
||||||
weather_source_store=weather_source_store,
|
weather_source_store=weather_source_store,
|
||||||
weather_manager=weather_manager,
|
weather_manager=weather_manager,
|
||||||
|
update_service=update_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -235,6 +250,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Start auto-backup engine (periodic configuration backups)
|
# Start auto-backup engine (periodic configuration backups)
|
||||||
await auto_backup_engine.start()
|
await auto_backup_engine.start()
|
||||||
|
|
||||||
|
# Start update checker (periodic release polling)
|
||||||
|
await update_service.start()
|
||||||
|
|
||||||
# Start OS notification listener (Windows toast → notification CSS streams)
|
# Start OS notification listener (Windows toast → notification CSS streams)
|
||||||
os_notif_listener = OsNotificationListener(
|
os_notif_listener = OsNotificationListener(
|
||||||
color_strip_store=color_strip_store,
|
color_strip_store=color_strip_store,
|
||||||
@@ -258,6 +276,12 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping weather manager: {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
|
# Stop auto-backup engine
|
||||||
try:
|
try:
|
||||||
await auto_backup_engine.stop()
|
await auto_backup_engine.stop()
|
||||||
|
|||||||
@@ -109,6 +109,58 @@ h2 {
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
letter-spacing: 0.03em;
|
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 {
|
.status-badge {
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ import {
|
|||||||
loadLogLevel, setLogLevel,
|
loadLogLevel, setLogLevel,
|
||||||
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
saveExternalUrl, getBaseOrigin, loadExternalUrl,
|
||||||
} from './features/settings.ts';
|
} from './features/settings.ts';
|
||||||
|
import {
|
||||||
|
loadUpdateStatus, initUpdateListener, checkForUpdates,
|
||||||
|
loadUpdateSettings, saveUpdateSettings, dismissUpdate,
|
||||||
|
initUpdateSettingsPanel, applyUpdate,
|
||||||
|
} from './features/update.ts';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
|
|
||||||
@@ -560,6 +565,14 @@ Object.assign(window, {
|
|||||||
saveExternalUrl,
|
saveExternalUrl,
|
||||||
getBaseOrigin,
|
getBaseOrigin,
|
||||||
|
|
||||||
|
// update
|
||||||
|
checkForUpdates,
|
||||||
|
loadUpdateSettings,
|
||||||
|
saveUpdateSettings,
|
||||||
|
dismissUpdate,
|
||||||
|
initUpdateSettingsPanel,
|
||||||
|
applyUpdate,
|
||||||
|
|
||||||
// appearance
|
// appearance
|
||||||
applyStylePreset,
|
applyStylePreset,
|
||||||
applyBgEffect,
|
applyBgEffect,
|
||||||
@@ -703,6 +716,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
startEntityEventListeners();
|
startEntityEventListeners();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
|
|
||||||
|
// Initialize update checker (banner + WS listener)
|
||||||
|
initUpdateListener();
|
||||||
|
loadUpdateStatus();
|
||||||
|
|
||||||
// Show getting-started tutorial on first visit
|
// Show getting-started tutorial on first visit
|
||||||
if (!localStorage.getItem('tour_completed')) {
|
if (!localStorage.getItem('tour_completed')) {
|
||||||
setTimeout(() => startGettingStartedTutorial(), 600);
|
setTimeout(() => startGettingStartedTutorial(), 600);
|
||||||
|
|||||||
@@ -81,3 +81,5 @@ export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2
|
|||||||
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
|
export const 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 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 circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
|
||||||
|
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
|
||||||
|
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
|
||||||
|
|||||||
@@ -183,3 +183,5 @@ export const ICON_HEADPHONES = _svg(P.headphones);
|
|||||||
export const ICON_TRASH = _svg(P.trash2);
|
export const ICON_TRASH = _svg(P.trash2);
|
||||||
export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
export const ICON_LIST_CHECKS = _svg(P.listChecks);
|
||||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||||
|
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
||||||
|
export const ICON_X = _svg(P.xIcon);
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export function switchSettingsTab(tabId: string): void {
|
|||||||
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
|
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
|
||||||
window.renderAppearanceTab();
|
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 ────────────────────────────────────────────
|
// ─── Log Viewer ────────────────────────────────────────────
|
||||||
|
|||||||
400
server/src/wled_controller/static/js/features/update.ts
Normal file
400
server/src/wled_controller/static/js/features/update.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Auto-update — check for new releases, show banner, manage settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '../core/api.ts';
|
||||||
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
|
import { t } from '../core/i18n.ts';
|
||||||
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
|
import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts';
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UpdateRelease {
|
||||||
|
version: string;
|
||||||
|
tag: string;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
published_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateStatus {
|
||||||
|
current_version: string;
|
||||||
|
has_update: boolean;
|
||||||
|
checking: boolean;
|
||||||
|
last_check: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
releases_url: string;
|
||||||
|
install_type: string;
|
||||||
|
can_auto_update: boolean;
|
||||||
|
downloading: boolean;
|
||||||
|
download_progress: number;
|
||||||
|
applying: boolean;
|
||||||
|
release: UpdateRelease | null;
|
||||||
|
dismissed_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _lastStatus: UpdateStatus | null = null;
|
||||||
|
|
||||||
|
// ─── Version badge highlight ────────────────────────────────
|
||||||
|
|
||||||
|
function _setVersionBadgeUpdate(hasUpdate: boolean): void {
|
||||||
|
const badge = document.getElementById('server-version');
|
||||||
|
if (!badge) return;
|
||||||
|
badge.classList.toggle('has-update', hasUpdate);
|
||||||
|
|
||||||
|
if (hasUpdate) {
|
||||||
|
badge.style.cursor = 'pointer';
|
||||||
|
badge.title = t('update.badge_tooltip');
|
||||||
|
badge.onclick = () => switchSettingsTabToUpdate();
|
||||||
|
} else {
|
||||||
|
badge.style.cursor = '';
|
||||||
|
badge.title = '';
|
||||||
|
badge.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSettingsTabToUpdate(): void {
|
||||||
|
if (typeof (window as any).openSettingsModal === 'function') {
|
||||||
|
(window as any).openSettingsModal();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof (window as any).switchSettingsTab === 'function') {
|
||||||
|
(window as any).switchSettingsTab('updates');
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update banner ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function _showBanner(status: UpdateStatus): void {
|
||||||
|
const release = status.release;
|
||||||
|
if (!release) return;
|
||||||
|
|
||||||
|
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||||
|
if (dismissed === release.version) return;
|
||||||
|
|
||||||
|
const banner = document.getElementById('update-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
|
||||||
|
const versionLabel = release.prerelease
|
||||||
|
? `${release.version} (${t('update.prerelease')})`
|
||||||
|
: release.version;
|
||||||
|
|
||||||
|
let actions = '';
|
||||||
|
|
||||||
|
// "Update Now" button if auto-update is supported
|
||||||
|
if (status.can_auto_update) {
|
||||||
|
actions += `<button class="btn btn-icon update-banner-action update-banner-apply" onclick="applyUpdate()" title="${t('update.apply_now')}">
|
||||||
|
${ICON_DOWNLOAD}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions += `<a href="${status.releases_url}" target="_blank" rel="noopener" class="btn btn-icon update-banner-action" title="${t('update.view_release')}">
|
||||||
|
${ICON_EXTERNAL_LINK}
|
||||||
|
</a>`;
|
||||||
|
|
||||||
|
actions += `<button class="btn btn-icon update-banner-action" onclick="dismissUpdate()" title="${t('update.dismiss')}">
|
||||||
|
${ICON_X}
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span class="update-banner-text">
|
||||||
|
${t('update.available').replace('{version}', versionLabel)}
|
||||||
|
</span>
|
||||||
|
${actions}
|
||||||
|
`;
|
||||||
|
banner.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideBanner(): void {
|
||||||
|
const banner = document.getElementById('update-banner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissUpdate(): void {
|
||||||
|
if (!_lastStatus?.release) return;
|
||||||
|
const version = _lastStatus.release.version;
|
||||||
|
localStorage.setItem('update-dismissed-version', version);
|
||||||
|
_hideBanner();
|
||||||
|
_setVersionBadgeUpdate(false);
|
||||||
|
|
||||||
|
fetchWithAuth('/system/update/dismiss', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ version }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Apply update ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function applyUpdate(): Promise<void> {
|
||||||
|
if (!_lastStatus?.release) return;
|
||||||
|
|
||||||
|
const version = _lastStatus.release.version;
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('update.apply_confirm').replace('{version}', version)
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
// Disable the apply button
|
||||||
|
const btns = document.querySelectorAll('.update-banner-apply, #update-apply-btn');
|
||||||
|
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/update/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
timeout: 600000, // 10 min for download + apply
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
// Server will shut down — the frontend reconnect overlay handles the rest
|
||||||
|
showToast(t('update.applying'), 'info');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('update.apply_error') + ': ' + (err as Error).message, 'error');
|
||||||
|
btns.forEach(b => (b as HTMLButtonElement).disabled = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status fetch (called on page load + WS event) ─────────
|
||||||
|
|
||||||
|
export async function loadUpdateStatus(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/update/status');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const status: UpdateStatus = await resp.json();
|
||||||
|
_lastStatus = status;
|
||||||
|
_applyStatus(status);
|
||||||
|
} catch {
|
||||||
|
// silent — non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyStatus(status: UpdateStatus): void {
|
||||||
|
const dismissed = localStorage.getItem('update-dismissed-version');
|
||||||
|
const hasVisibleUpdate = status.has_update
|
||||||
|
&& status.release != null
|
||||||
|
&& status.release.version !== dismissed;
|
||||||
|
|
||||||
|
_setVersionBadgeUpdate(hasVisibleUpdate);
|
||||||
|
|
||||||
|
if (hasVisibleUpdate) {
|
||||||
|
_showBanner(status);
|
||||||
|
} else {
|
||||||
|
_hideBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderUpdatePanel(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WS event handlers ─────────────────────────────────────
|
||||||
|
|
||||||
|
export function initUpdateListener(): void {
|
||||||
|
document.addEventListener('server:update_available', ((e: CustomEvent) => {
|
||||||
|
const data = e.detail;
|
||||||
|
if (data && data.version && !data.dismissed) {
|
||||||
|
loadUpdateStatus();
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
// Download progress
|
||||||
|
document.addEventListener('server:update_download_progress', ((e: CustomEvent) => {
|
||||||
|
const progress = e.detail?.progress;
|
||||||
|
if (typeof progress === 'number') {
|
||||||
|
_updateProgressBar(progress);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateProgressBar(progress: number): void {
|
||||||
|
const bar = document.getElementById('update-progress-bar');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = `${Math.round(progress * 100)}%`;
|
||||||
|
bar.parentElement!.style.display = progress > 0 && progress < 1 ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Manual check ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function checkForUpdates(): Promise<void> {
|
||||||
|
const btn = document.getElementById('update-check-btn') as HTMLButtonElement | null;
|
||||||
|
const spinner = document.getElementById('update-check-spinner');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (spinner) spinner.style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const status: UpdateStatus = await resp.json();
|
||||||
|
_lastStatus = status;
|
||||||
|
_applyStatus(status);
|
||||||
|
|
||||||
|
if (status.has_update && status.release) {
|
||||||
|
showToast(t('update.available').replace('{version}', status.release.version), 'info');
|
||||||
|
} else {
|
||||||
|
showToast(t('update.up_to_date'), 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('update.check_error') + ': ' + (err as Error).message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
if (spinner) spinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings panel ────────────────────────────────────────
|
||||||
|
|
||||||
|
let _channelIconSelect: IconSelect | null = null;
|
||||||
|
|
||||||
|
function _getChannelItems(): { value: string; icon: string; label: string; desc: string }[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'false',
|
||||||
|
icon: '<span style="color:#4CAF50;font-weight:700">S</span>',
|
||||||
|
label: t('update.channel.stable'),
|
||||||
|
desc: t('update.channel.stable_desc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'true',
|
||||||
|
icon: '<span style="color:#ff9800;font-weight:700">P</span>',
|
||||||
|
label: t('update.channel.prerelease'),
|
||||||
|
desc: t('update.channel.prerelease_desc'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initUpdateSettingsPanel(): void {
|
||||||
|
if (!_channelIconSelect) {
|
||||||
|
const sel = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||||
|
if (sel) {
|
||||||
|
_channelIconSelect = new IconSelect({
|
||||||
|
target: sel,
|
||||||
|
items: _getChannelItems(),
|
||||||
|
columns: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUpdateSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/update/settings');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
|
||||||
|
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
|
||||||
|
const channelEl = document.getElementById('update-channel') as HTMLSelectElement | null;
|
||||||
|
|
||||||
|
if (enabledEl) enabledEl.checked = data.enabled;
|
||||||
|
if (intervalEl) intervalEl.value = String(data.check_interval_hours);
|
||||||
|
if (_channelIconSelect) {
|
||||||
|
_channelIconSelect.setValue(String(data.include_prerelease));
|
||||||
|
} else if (channelEl) {
|
||||||
|
channelEl.value = String(data.include_prerelease);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load update settings:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadUpdateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUpdateSettings(): Promise<void> {
|
||||||
|
const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true;
|
||||||
|
const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24';
|
||||||
|
const check_interval_hours = parseFloat(intervalStr);
|
||||||
|
const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false';
|
||||||
|
const include_prerelease = channelVal === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth('/system/update/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
showToast(t('update.settings_saved'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderUpdatePanel(status: UpdateStatus): void {
|
||||||
|
const currentEl = document.getElementById('update-current-version');
|
||||||
|
if (currentEl) currentEl.textContent = `v${status.current_version}`;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('update-status-text');
|
||||||
|
if (statusEl) {
|
||||||
|
if (status.has_update && status.release) {
|
||||||
|
statusEl.textContent = t('update.available').replace('{version}', status.release.version);
|
||||||
|
statusEl.style.color = 'var(--warning-color)';
|
||||||
|
} else if (status.last_error) {
|
||||||
|
statusEl.textContent = t('update.check_error') + ': ' + status.last_error;
|
||||||
|
statusEl.style.color = 'var(--danger-color)';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = t('update.up_to_date');
|
||||||
|
statusEl.style.color = 'var(--primary-color)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCheckEl = document.getElementById('update-last-check');
|
||||||
|
if (lastCheckEl) {
|
||||||
|
if (status.last_check) {
|
||||||
|
lastCheckEl.textContent = t('update.last_check') + ': ' + new Date(status.last_check * 1000).toLocaleString();
|
||||||
|
} else {
|
||||||
|
lastCheckEl.textContent = t('update.last_check') + ': ' + t('update.never');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install type info
|
||||||
|
const installEl = document.getElementById('update-install-type');
|
||||||
|
if (installEl) {
|
||||||
|
installEl.textContent = t(`update.install_type.${status.install_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Update Now" button in settings panel
|
||||||
|
const applyBtn = document.getElementById('update-apply-btn') as HTMLButtonElement | null;
|
||||||
|
if (applyBtn) {
|
||||||
|
const show = status.has_update && status.can_auto_update;
|
||||||
|
applyBtn.style.display = show ? '' : 'none';
|
||||||
|
applyBtn.disabled = status.downloading || status.applying;
|
||||||
|
if (status.downloading) {
|
||||||
|
applyBtn.textContent = `${t('update.downloading')} ${Math.round(status.download_progress * 100)}%`;
|
||||||
|
} else if (status.applying) {
|
||||||
|
applyBtn.textContent = t('update.applying');
|
||||||
|
} else {
|
||||||
|
applyBtn.textContent = t('update.apply_now');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
const progressBar = document.getElementById('update-progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
const show = status.downloading && status.download_progress > 0 && status.download_progress < 1;
|
||||||
|
progressBar.style.width = `${Math.round(status.download_progress * 100)}%`;
|
||||||
|
progressBar.parentElement!.style.display = show ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release notes preview
|
||||||
|
const notesEl = document.getElementById('update-release-notes');
|
||||||
|
if (notesEl) {
|
||||||
|
if (status.has_update && status.release && status.release.body) {
|
||||||
|
const truncated = status.release.body.length > 500
|
||||||
|
? status.release.body.slice(0, 500) + '...'
|
||||||
|
: status.release.body;
|
||||||
|
notesEl.textContent = truncated;
|
||||||
|
notesEl.parentElement!.style.display = '';
|
||||||
|
} else {
|
||||||
|
notesEl.textContent = '';
|
||||||
|
notesEl.parentElement!.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1917,6 +1917,43 @@
|
|||||||
"appearance.bg.scanlines": "Scanlines",
|
"appearance.bg.scanlines": "Scanlines",
|
||||||
"appearance.bg.applied": "Background effect applied",
|
"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": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "Search notification apps…"
|
"search_apps": "Search notification apps…"
|
||||||
|
|||||||
@@ -1846,6 +1846,43 @@
|
|||||||
"appearance.bg.scanlines": "Развёртка",
|
"appearance.bg.scanlines": "Развёртка",
|
||||||
"appearance.bg.applied": "Фоновый эффект применён",
|
"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": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "Поиск приложений…"
|
"search_apps": "Поиск приложений…"
|
||||||
|
|||||||
@@ -1844,6 +1844,43 @@
|
|||||||
"appearance.bg.scanlines": "扫描线",
|
"appearance.bg.scanlines": "扫描线",
|
||||||
"appearance.bg.applied": "背景效果已应用",
|
"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": {
|
"color_strip": {
|
||||||
"notification": {
|
"notification": {
|
||||||
"search_apps": "搜索通知应用…"
|
"search_apps": "搜索通知应用…"
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="update-banner" class="update-banner" style="display:none"></div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
<button class="settings-tab-btn" data-settings-tab="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="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="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>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -206,6 +207,84 @@
|
|||||||
<!-- Rendered dynamically by renderAppearanceTab() -->
|
<!-- Rendered dynamically by renderAppearanceTab() -->
|
||||||
</div>
|
</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 id="settings-error" class="error-message" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user