diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bc9e4f..253ac38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,13 @@ jobs: requirements.txt requirements-dev.txt + - name: Install Linux tray build prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libcairo2-dev libgirepository1.0-dev \ + gir1.2-ayatanaappindicator3-0.1 + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/cross-platform-smoke.yml b/.github/workflows/cross-platform-smoke.yml index 4091e07..79e5d10 100644 --- a/.github/workflows/cross-platform-smoke.yml +++ b/.github/workflows/cross-platform-smoke.yml @@ -24,8 +24,8 @@ jobs: with: python-version: "3.13" - - name: Install test runner - run: python -m pip install "pytest>=8.0" + - name: Install platform-boundary test dependencies + run: python -m pip install "pytest>=8.0" "pystray>=0.19.5" "Pillow>=10.0.0" - name: Compile platform boundary run: python -m compileall -q core/platform tests/test_platform_capabilities.py tests/test_platform_linux.py diff --git a/.github/workflows/linux-beta-build.yml b/.github/workflows/linux-beta-build.yml new file mode 100644 index 0000000..6bc09e7 --- /dev/null +++ b/.github/workflows/linux-beta-build.yml @@ -0,0 +1,73 @@ +name: Linux beta build + +on: + push: + branches: + - feature/v1.2.0-linux-beta + - "release/**" + pull_request: + paths: + - "**.py" + - "pyproject.toml" + - "requirements*.txt" + - "scripts/build_linux.sh" + - ".github/workflows/linux-beta-build.yml" + +permissions: + contents: read + +jobs: + package-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + # Match the embedded runtime used by the current Flet Linux build. + python-version: "3.12" + + - name: Install Flet Linux prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + binutils clang cmake llvm lld ninja-build pkg-config \ + libcairo2-dev libgirepository1.0-dev \ + gir1.2-ayatanaappindicator3-0.1 \ + libgtk-3-dev libsecret-1-0 libsecret-1-dev libunwind-dev \ + gstreamer1.0-alsa gstreamer1.0-gl gstreamer1.0-gtk3 \ + gstreamer1.0-libav gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good gstreamer1.0-tools gstreamer1.0-x + + - name: Install Python dependencies + run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt + + - name: Test and package Linux beta + run: bash scripts/build_linux.sh --output dist/linux + + - name: Verify application-menu installer + shell: bash + run: | + set -euo pipefail + export XDG_DATA_HOME="$RUNNER_TEMP/wizz-xdg-data" + bash dist/linux/install.sh + + desktop_file="$XDG_DATA_HOME/applications/io.github.yvvvl.wizz-controller.desktop" + installed_app="$XDG_DATA_HOME/WizZDesktop/WizZDesktop" + test -f "$desktop_file" + test -x "$installed_app" + grep -F "Exec=$installed_app" "$desktop_file" + grep -F "Icon=$XDG_DATA_HOME/WizZDesktop/assets/icon.png" "$desktop_file" + + bash "$XDG_DATA_HOME/WizZDesktop/uninstall.sh" + test ! -e "$desktop_file" + test ! -e "$XDG_DATA_HOME/WizZDesktop" + + - uses: actions/upload-artifact@v4 + with: + name: WizZDesktop-v1.2.0-linux-x64 + path: | + dist/linux + dist/release/WizZDesktop-v1.2.0-linux-x64.tar.gz + dist/release/WizZDesktop-v1.2.0-linux-x64.tar.gz.sha256 + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index d761fc1..e91d54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## v1.2.0 + +### Added + +- Selección temporal de una, varias o todas las ampolletas, sin necesidad de + crear ni editar grupos persistentes. +- Comprobación manual y segura de actualizaciones desde GitHub Releases. +- Fixture de desarrollo con ampolletas virtuales para validar targeting sin + necesitar hardware múltiple. +- Beta Linux para Ubuntu Desktop: paquete nativo, persistencia XDG, + AppIndicator, apertura de Datos/Logs y arranque automático por usuario. +- Instalador Linux sin `sudo`, con lanzador en el menú de aplicaciones y + desinstalación que conserva los datos personales. + +### Changed + +- Las builds Windows guardan configuraciones y logs en + `%LOCALAPPDATA%\\WizZDesktop` y migran datos de instalaciones Flet previas. +- Un clic en el icono de bandeja restaura directamente la ventana principal. +- El Quick Panel experimental fue retirado del flujo público de la bandeja. +- En Linux, los hotkeys globales se muestran como no disponibles: no se usa + `sudo` ni se intentan hooks inseguros sobre dispositivos de entrada. + +### Fixed + +- Restauración de ventana, salida real desde la bandeja e instancia única en + builds Windows. +- Persistencia de datos al reemplazar o ejecutar una copia aislada del + ejecutable. +- El paquete Linux ahora incluye las dependencias PyGObject/AppIndicator que + necesita el icono de bandeja fuera del entorno de desarrollo. + +### Validation + +- 368 pruebas automatizadas completadas en Windows y Ubuntu Desktop; auditoría + i18n: 608 claves en inglés y español, sin strings UI hardcodeados. +- Validación Windows real: bandeja, hotkeys, instancia única, AppData, + extracción limpia y control de una ampolleta WiZ por LAN. +- Validación Linux beta real: build nativa, archivo extraído, bandeja, + persistencia XDG y control LAN de la misma ampolleta WiZ. + +> RGBIC, Screen Sync, streaming y actualización automática no forman parte de +> esta versión estable. + ## v1.1.0 ### Added diff --git a/README.md b/README.md index 9e748eb..203eee3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # WizZ Desktop -### Control local, rápido y privado para ampolletas WiZ en Windows +### Control local, rápido y privado para ampolletas WiZ [![Release](https://img.shields.io/github/v/release/yvvvl/WizzController?label=release)](https://github.com/yvvvl/WizzController/releases/latest) [![CI](https://github.com/yvvvl/WizzController/actions/workflows/ci.yml/badge.svg)](https://github.com/yvvvl/WizzController/actions/workflows/ci.yml) @@ -24,22 +24,25 @@ Las acciones normales se envían por **UDP LAN nativo**, por lo que el control no depende de la nube de WiZ y mantiene una respuesta rápida incluso cuando la conexión a Internet no está disponible. -La aplicación está diseñada para Windows y combina control de iluminación, automatizaciones, hotkeys globales y una interfaz moderna en un único programa portable. +La aplicación combina control de iluminación, automatizaciones y una interfaz +moderna en un único programa portable. Windows es la plataforma estable; +Linux se entrega como beta para Ubuntu Desktop y otros escritorios compatibles. -> Versión actual: **v1.1.0 · build 1** +> Candidato de release actual: **v1.2.0 · build 2** -## Novedades v1.1.0 +## Novedades v1.2.0 -- Interfaz completa en español e inglés. -- Detección automática del idioma del sistema. -- Selector manual de idioma. -- Nuevo editor inteligente de favoritos: - - RGB usando Color Studio. - - Blanco mediante temperatura Kelvin. - - Escenas WiZ. - - Control dedicado de brillo. -- Mejoras de estabilidad en Windows. -- Distribución portable con licencias de terceros incluidas. +- Selección temporal de una, varias o todas las ampolletas. +- Comprobación manual y segura de actualizaciones desde GitHub Releases. +- Configuración y logs persistentes en AppData Local para builds Windows. +- Restauración más predecible de la ventana desde la bandeja. +- Validación real de control WiZ, tray, hotkeys, instancia única y ejecutable + aislado en Windows. +- Beta Linux: persistencia XDG, bandeja AppIndicator en GNOME/Wayland, + apertura de Datos/Logs y arranque automático por usuario. + +> RGBIC, Screen Sync, streaming y actualización automática no están incluidos +> en esta versión estable. --- @@ -84,7 +87,7 @@ La aplicación está diseñada para Windows y combina control de iluminación, a - Hotkeys globales nativas mediante `RegisterHotKey`. - Fallback selectivo usando `keyboard` cuando una combinación está ocupada. - System tray con acciones rápidas. -- Doble clic en el icono de bandeja para mostrar u ocultar la ventana. +- Un clic en el icono de bandeja para restaurar la ventana principal. - Cierre a bandeja. - Inicio minimizado. - Inicio automático con Windows. @@ -104,7 +107,7 @@ La aplicación está diseñada para Windows y combina control de iluminación, a ## Instalación para usuarios -### Requisitos +### Windows estable — requisitos - Windows 10 u 11 de 64 bits. - Una ampolleta WiZ conectada a la misma red local que el PC. @@ -112,7 +115,7 @@ La aplicación está diseñada para Windows y combina control de iluminación, a ### Pasos 1. Abre la [última release](https://github.com/yvvvl/WizzController/releases/latest). -2. Descarga `WizZDesktop-v1.1.0-windows-x64.zip`. +2. Descarga `WizZDesktop-v1.2.0-windows-x64.zip`. 3. Extrae todo el contenido del ZIP. 4. Ejecuta `WizZDesktop.exe`. @@ -120,16 +123,37 @@ La aplicación está diseñada para Windows y combina control de iluminación, a La descarga incluye un archivo `.sha256` para comprobar la integridad del paquete. +### Linux beta — Ubuntu Desktop + +La beta Linux se distribuye como `WizZDesktop-v1.2.0-linux-x64.tar.gz` con su +archivo `.sha256`. Extrae el archivo, abre una terminal dentro de la carpeta +extraída y ejecuta `./install.sh`. No requiere `sudo`: instala la app para tu +usuario, crea el acceso **WizZ Desktop** en Aplicaciones y conserva tus datos +al actualizar. Luego puedes abrirla desde Actividades y anclarla al dock. + +Para retirar la aplicación instalada, ejecuta +`~/.local/share/WizZDesktop/uninstall.sh`. Esto elimina la app y su lanzador, +pero conserva tus configuraciones, ampolletas, favoritos y logs. + +También puedes ejecutar `./WizZDesktop` directamente desde la carpeta +extraída si prefieres usarla en modo portable. La plataforma validada es Ubuntu +Desktop con GNOME; en Wayland, la posición de ventana la decide el compositor. + +La bandeja requiere un escritorio compatible con AppIndicator. Si no está +disponible, la aplicación sigue siendo usable como ventana normal. Los hotkeys +globales están deshabilitados intencionalmente en Linux beta hasta contar con +un backend seguro basado en el portal XDG. + ### Verificar SHA-256 en PowerShell ```powershell -Get-FileHash .\WizZDesktop-v1.1.0-windows-x64.zip -Algorithm SHA256 +Get-FileHash .\WizZDesktop-v1.2.0-windows-x64.zip -Algorithm SHA256 ``` Compara el resultado con el contenido de: ```text -WizZDesktop-v1.1.0-windows-x64.zip.sha256 +WizZDesktop-v1.2.0-windows-x64.zip.sha256 ``` --- @@ -152,7 +176,8 @@ Si eliminas una ampolleta, permanecerá fuera de la lista hasta que realices una - Python `>=3.11,<3.14`. - Flet `0.85.2`. -- Windows para tray, hotkeys nativas y build final. +- Windows para la build estable de Windows. +- Ubuntu Desktop o WSL para la build beta Linux. ### Preparar el entorno @@ -208,8 +233,8 @@ WizZ Desktop utiliza `flet build windows`; no usa PyInstaller. ```text dist/windows/WizZDesktop.exe dist/windows/BUILD_INFO.json -dist/release/WizZDesktop-v1.0.0-windows-x64.zip -dist/release/WizZDesktop-v1.0.0-windows-x64.zip.sha256 +dist/release/WizZDesktop-v1.2.0-windows-x64.zip +dist/release/WizZDesktop-v1.2.0-windows-x64.zip.sha256 ``` ### Smoke test @@ -223,6 +248,44 @@ La guía completa está en --- +## Build nativa para Linux beta + +En Ubuntu 22.04 instala las dependencias de compilación y AppIndicator: + +```bash +sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev \ + lld-14 libcairo2-dev libgirepository1.0-dev \ + gir1.2-ayatanaappindicator3-0.1 +``` + +Luego crea el entorno Python del proyecto e instala sus dependencias: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt +``` + +Finalmente ejecuta: + +```bash +source .venv/bin/activate +bash scripts/build_linux.sh --clean +``` + +Las salidas son: + +```text +dist/linux/WizZDesktop +dist/linux/BUILD_INFO.json +dist/linux/install.sh +dist/linux/uninstall.sh +dist/release/WizZDesktop-v1.2.0-linux-x64.tar.gz +dist/release/WizZDesktop-v1.2.0-linux-x64.tar.gz.sha256 +``` + +--- + ## Datos y privacidad WizZ Desktop no necesita una cuenta propia ni una base de datos remota para controlar las luces por LAN. @@ -233,7 +296,18 @@ En desarrollo, los archivos locales viven en: config/json/ ``` -En el ejecutable, configuraciones y logs se guardan en el almacenamiento persistente asignado por Flet. +En el ejecutable Windows, configuraciones y logs se guardan en: + +```text +%LOCALAPPDATA%\WizZDesktop +``` + +Las instalaciones Flet previas se migran automáticamente la primera vez que +se ejecuta esta versión. + +En la build Linux, Flet proporciona el almacenamiento de usuario. Cuando no +existe ese directorio, WizZ usa la ruta XDG del usuario bajo +`~/.config/WizZDesktop`. Puedes abrir las ubicaciones reales desde: @@ -320,7 +394,8 @@ python tools/probe_remove_active_bulb.py --ip 192.168.1.4 ## Estado del proyecto -La versión `v1.0.0` representa la primera release estable y portable para Windows x64. +La versión `v1.2.0` es el candidato actual para la siguiente release estable +portable de Windows x64. El proyecto cuenta con pruebas automatizadas para: diff --git a/app_meta.py b/app_meta.py index 16ab33a..fc73e6e 100644 --- a/app_meta.py +++ b/app_meta.py @@ -12,8 +12,8 @@ APP_ARTIFACT = "WizZDesktop" APP_ID = "WizZDesktop" APP_REGISTRY_NAME = "WizZController" -APP_VERSION = "1.1.0" -APP_BUILD_NUMBER = 1 +APP_VERSION = "1.2.0" +APP_BUILD_NUMBER = 2 APP_DESCRIPTION = "Control local de ampolletas WiZ por LAN." APP_COMPANY = "yvvvl" APP_COPYRIGHT = "Copyright © 2026 Ignacio" diff --git a/config/app_runtime_manager.py b/config/app_runtime_manager.py index 776bf4a..50fd6b0 100644 --- a/config/app_runtime_manager.py +++ b/config/app_runtime_manager.py @@ -3,19 +3,20 @@ import json import logging import os +import shlex import subprocess import sys import threading from pathlib import Path from typing import Any, Iterable -from app_meta import APP_REGISTRY_NAME +from app_meta import APP_PRODUCT, APP_REGISTRY_NAME from config.paths import config_dir, project_root from localization import LocalizationManager class AppRuntimeManager: - """Configuración de arranque, bandeja e inicio con Windows. + """Configuración de arranque, bandeja e inicio de sesión del escritorio. En desarrollo guarda en ``config/json``. En un build Flet usa el storage persistente de la aplicación y resuelve el launcher real para el registro @@ -97,31 +98,41 @@ def update(self, **kwargs: Any) -> None: self._save_dict(self.data) # ------------------------------------------------------------------ # - # Windows Startup + # Desktop startup # ------------------------------------------------------------------ # def _startup_command(self) -> str: packaged = resolve_packaged_executable() if packaged is not None: - return subprocess.list2cmdline([str(packaged)]) + if sys.platform.startswith("win"): + return subprocess.list2cmdline([str(packaged)]) + return shlex.join([str(packaged)]) main_py = project_root() / "main.py" py = Path(sys.executable) - pythonw = py.with_name("pythonw.exe") if os.name == "nt" else py + pythonw = py.with_name("pythonw.exe") if sys.platform.startswith("win") else py executable = pythonw if pythonw.exists() else py - return subprocess.list2cmdline([str(executable), str(main_py)]) + if sys.platform.startswith("win"): + return subprocess.list2cmdline([str(executable), str(main_py)]) + return shlex.join([str(executable), str(main_py)]) def _sync_startup_registration(self) -> None: """Mantiene coherente el JSON y el valor ``Run`` de Windows. Si el usuario ya activó el arranque automático y mueve/actualiza la - build, se reescribe la ruta con el launcher actual. En otros sistemas - la preferencia se normaliza a ``False``. + build, se reescribe la ruta con el launcher actual. En Linux se + mantiene una entrada XDG por usuario; otras plataformas no se fuerzan. """ - if os.name != "nt": - if self.data.get("startup_with_windows"): - self.data["startup_with_windows"] = False - self._save_dict(self.data) + if sys.platform.startswith("linux"): + if not bool(self.data.get("startup_with_windows")): + return + try: + self._linux_autostart_service().set_enabled(True) + except Exception as exc: + logging.warning("[Startup] No se pudo sincronizar inicio Linux: %s", exc) + return + + if not sys.platform.startswith("win"): return if not bool(self.data.get("startup_with_windows")): @@ -137,7 +148,25 @@ def _sync_startup_registration(self) -> None: logging.warning("[Startup] No se pudo sincronizar inicio con Windows: %s", exc) def set_startup_with_windows(self, enabled: bool) -> tuple[bool, str]: - if os.name != "nt": + """Persist legacy key while applying the native platform mechanism. + + The persisted key keeps backwards compatibility with v1.1 settings. + Its user-facing meaning is now "start at login" on supported desktops. + """ + if sys.platform.startswith("linux"): + try: + ok = self._linux_autostart_service().set_enabled(enabled) + except Exception as exc: + return False, self._t("runtime.startup.update_error", error=exc) + if not ok: + return False, self._t("runtime.startup.linux_unavailable") + with self._lock: + self.data["startup_with_windows"] = bool(enabled) + self._save_dict(self.data) + key = "runtime.startup.updated" if enabled else "runtime.startup.disabled" + return True, self._t(key) + + if not sys.platform.startswith("win"): with self._lock: self.data["startup_with_windows"] = False self._save_dict(self.data) @@ -157,6 +186,15 @@ def set_startup_with_windows(self, enabled: bool) -> tuple[bool, str]: key = "runtime.startup.updated" if enabled else "runtime.startup.disabled" return True, self._t(key) + def _linux_autostart_service(self): + """Create the XDG adapter lazily so Windows stays independent of it.""" + from core.platform.linux import LinuxAutostartService + + return LinuxAutostartService( + desktop_entry=self._startup_command(), + application_name=APP_PRODUCT, + ) + def _registry_key_path() -> str: return r"Software\Microsoft\Windows\CurrentVersion\Run" diff --git a/config/hotkeys_manager.py b/config/hotkeys_manager.py index 9776acc..1a758d5 100644 --- a/config/hotkeys_manager.py +++ b/config/hotkeys_manager.py @@ -4,6 +4,7 @@ import logging import os import re +import sys import threading import time from typing import Any, Callable @@ -139,15 +140,26 @@ def _migrate(self) -> None: @property def available(self) -> bool: """True si existe algún backend global utilizable.""" - return os.name == "nt" or _keyboard is not None + # ``keyboard`` necesita acceder a /dev/input en Linux. En Wayland eso + # implica ejecutar como root, algo que no es aceptable para una app de + # escritorio ni resuelve el modelo de permisos del compositor. Hasta + # implementar el portal XDG GlobalShortcuts, Linux se declara como no + # compatible y nunca intenta registrar hooks. + if sys.platform.startswith("linux"): + return False + return sys.platform.startswith("win") or _keyboard is not None @property def can_record(self) -> bool: """La grabación interactiva sigue usando la librería keyboard.""" + if sys.platform.startswith("linux"): + return False return _keyboard is not None def dependency_message(self) -> str: - if os.name == "nt": + if sys.platform.startswith("linux"): + return self._t("hotkeys.dependency.linux_unavailable") + if sys.platform.startswith("win"): if _keyboard is None: return self._t("hotkeys.dependency.native_recording_missing", error=_IMPORT_ERROR) return self._t("hotkeys.dependency.native_ready") @@ -567,6 +579,10 @@ def apply_hooks(self) -> None: "keyboard": 0, "failed": [], } + if not self.available: + self.last_error = self.dependency_message() + self._registration_report["failed"] = self._public_failures(entries) + return if not self.is_enabled(): return if not entries: @@ -577,7 +593,7 @@ def apply_hooks(self) -> None: native_success: list[dict[str, Any]] = [] native_failed: list[dict[str, Any]] = [] - if os.name == "nt" and preference in {"auto", "native"}: + if sys.platform.startswith("win") and preference in {"auto", "native"}: try: self._native_backend.start(entries) native_success = self._native_backend.successful_entries diff --git a/config/paths.py b/config/paths.py index 471996b..2a979ec 100644 --- a/config/paths.py +++ b/config/paths.py @@ -30,9 +30,26 @@ def assets_dir() -> Path: def is_flet_build() -> bool: + executable_name = Path(sys.executable).name.lower() + executable_dir = Path(sys.executable).resolve().parent + packaged_marker = executable_dir / f"{APP_ARTIFACT}.exe" + argv_name = Path(str(sys.argv[0] or "")).name.lower() + # Flet's Windows launcher can execute Python from the bundled app.zip + # rather than from WizZDesktop.exe itself. In that mode neither + # ``sys.frozen`` nor the executable name is a reliable marker. + module_path = str(Path(__file__).resolve()).lower() + embedded_runtime = ( + ".zip" in module_path + or "flutter_assets" in module_path + or Path(__file__).suffix.lower() == ".pyc" + ) return bool( str(os.environ.get("FLET_APP_STORAGE_DATA") or "").strip() or getattr(sys, "frozen", False) + or executable_name == f"{APP_ARTIFACT.lower()}.exe" + or packaged_marker.is_file() + or argv_name == f"{APP_ARTIFACT.lower()}.exe" + or embedded_runtime ) @@ -47,18 +64,31 @@ def config_dir() -> Path: flet_storage = str(os.environ.get("FLET_APP_STORAGE_DATA") or "").strip() - # 2. Si es una build empaquetada (AppImage / PyInstaller / Flet Build) + # 2. Windows packaged builds always use LocalAppData. Flet sets + # FLET_APP_STORAGE_DATA to its Documents-based folder, but WizZ's release + # contract is one predictable Windows location that survives upgrades. if is_flet_build(): - if flet_storage: - target = Path(flet_storage).expanduser().resolve() / "config" + if sys.platform.startswith("win"): + local_app_data = os.environ.get( + "LOCALAPPDATA", str(Path.home() / "AppData" / "Local") + ) + target = Path(local_app_data) / APP_ARTIFACT / "config" return _prepare(target, migrate=True) - # Estándar Linux XDG si no hay ruta Flet explícita + # Linux packages use the freedesktop.org XDG location even when Flet + # supplies an internal storage folder. That keeps data predictable + # across GNOME, KDE, XFCE and other desktop environments. The Flet + # folder remains a one-time migration source below. if sys.platform.startswith("linux"): xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) target = Path(xdg_config) / APP_ARTIFACT / "config" return _prepare(target, migrate=True) + # Other packaged platforms honor Flet's explicit storage directory. + if flet_storage: + target = Path(flet_storage).expanduser().resolve() / "config" + return _prepare(target, migrate=True) + # 3. Modo Desarrollo Local (Guarda en el propio repo config/json) target = project_root() / "config" / "json" return _prepare(target, migrate=False) @@ -68,11 +98,16 @@ def logs_dir() -> Path: """Directorio para los logs de la aplicación.""" flet_storage = str(os.environ.get("FLET_APP_STORAGE_DATA") or "").strip() - if flet_storage: - target = Path(flet_storage).expanduser().resolve() / "logs" + if sys.platform.startswith("win") and is_flet_build(): + local_app_data = os.environ.get( + "LOCALAPPDATA", str(Path.home() / "AppData" / "Local") + ) + target = Path(local_app_data) / APP_ARTIFACT / "logs" elif sys.platform.startswith("linux") and is_flet_build(): - xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) - target = Path(xdg_config) / APP_ARTIFACT / "logs" + xdg_state = os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")) + target = Path(xdg_state) / APP_ARTIFACT / "logs" + elif flet_storage: + target = Path(flet_storage).expanduser().resolve() / "logs" else: target = project_root() / "logs" @@ -145,6 +180,12 @@ def _legacy_candidates() -> list[Path]: if explicit: candidates.append(Path(explicit).expanduser()) + # Earlier Flet Windows builds stored the application data under Documents. + # Keep it as a migration source when moving those users to LocalAppData. + flet_storage = str(os.environ.get("FLET_APP_STORAGE_DATA") or "").strip() + if flet_storage: + candidates.append(Path(flet_storage).expanduser() / "config") + candidates.extend( [ Path.cwd() / "config" / "json", @@ -162,4 +203,4 @@ def _legacy_candidates() -> list[Path]: continue seen.add(key) unique.append(item) - return unique \ No newline at end of file + return unique diff --git a/core/background/tray_service.py b/core/background/tray_service.py index 821fc0a..2429101 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -3,6 +3,7 @@ import asyncio import inspect import os +import sys import threading import time from typing import Any, Callable @@ -66,6 +67,7 @@ def __init__( self._double_click_seconds = self._system_double_click_seconds() self._unsubscribe_i18n = self.i18n.subscribe(lambda language: self.refresh_menu()) try: + self._configure_linux_backend() import pystray # type: ignore from PIL import Image, ImageDraw # type: ignore @@ -77,6 +79,24 @@ def __init__( self.available = False self.last_error = f"pystray/Pillow no disponible: {exc}" + @staticmethod + def _configure_linux_backend() -> None: + """Prefer the desktop-status-item backend on GNOME/Wayland. + + Ubuntu GNOME exposes AppIndicator items through its indicator + extension. The generic Xorg backend can create an inert icon there, + so select AppIndicator before pystray is imported. An explicit user + choice always wins and non-Linux platforms are untouched. + """ + if not sys.platform.startswith("linux"): + return + if str(os.environ.get("PYSTRAY_BACKEND") or "").strip(): + return + session = str(os.environ.get("XDG_SESSION_TYPE") or "").strip().lower() + desktop = str(os.environ.get("XDG_CURRENT_DESKTOP") or "").casefold() + if session == "wayland" or "gnome" in desktop or "ubuntu" in desktop: + os.environ["PYSTRAY_BACKEND"] = "appindicator" + def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) @@ -169,11 +189,7 @@ def start(self) -> bool: menu=self._build_menu(), ) - if hasattr(self.icon, "run_detached"): - self.icon.run_detached() - else: - self._thread = threading.Thread(target=self.icon.run, name="WizZTray", daemon=True) - self._thread.start() + self._start_icon_loop() self.started = True self.last_error = None @@ -186,6 +202,25 @@ def start(self) -> bool: self._log(self.last_error) return False + def _start_icon_loop(self) -> None: + """Start the native tray loop using the correct ownership model. + + ``run_detached()`` only works when the host UI provides the same + GObject main loop. Flet does not, so AppIndicator on Linux remains + invisible when merely prepared as detached. Run its loop in a dedicated + worker thread instead. Windows keeps its existing detached integration. + """ + assert self.icon is not None + if sys.platform.startswith("linux") or not hasattr(self.icon, "run_detached"): + self._thread = threading.Thread( + target=self.icon.run, + name="WizZTray", + daemon=True, + ) + self._thread.start() + return + self.icon.run_detached() + def _build_menu(self): pystray = self._pystray assert pystray is not None @@ -255,10 +290,9 @@ def _build_menu(self): if os.name == "nt" and has_quick_panel: primary_action_items.append( item( - self._t("tray.toggle_app"), + self._t("tray.quick_panel"), lambda icon, it: self._handle_tray_primary_click(), default=True, - visible=False, ) ) if has_quick_panel: @@ -280,7 +314,9 @@ def _build_menu(self): item( self._t("tray.show_app"), lambda icon, it: self.show_window(), - default=os.name != "nt", + # The public desktop build has one clear tray action: + # restore the full application on every supported OS. + default=True, ) ) @@ -357,7 +393,7 @@ def _hotkey_menu_items(self): pystray = self._pystray assert pystray is not None item = pystray.MenuItem - if self.hotkeys_manager is None: + if self.hotkeys_manager is None or not self.hotkeys_manager.available: return [] status = self._t( "tray.hotkeys_status", @@ -387,6 +423,13 @@ def _make_icon(self): ImageDraw = self._ImageDraw assert Image is not None and ImageDraw is not None + # AppIndicator reduces status icons aggressively. The branded PNG is + # legible on Windows but can become visually empty on Ubuntu GNOME. + # Use a compact opaque glyph there; it is intentionally independent + # from the window/taskbar artwork. + if sys.platform.startswith("linux"): + return self._make_linux_indicator_icon(Image, ImageDraw) + for filename in ("tray_icon.png", "icon_windows.png", "icon.png"): try: path = assets_dir() / filename @@ -407,6 +450,16 @@ def _make_icon(self): d.rectangle((22, 47, 42, 51), fill=(167, 139, 250, 255)) return img + @staticmethod + def _make_linux_indicator_icon(Image, ImageDraw): + """Create a high-contrast status glyph for AppIndicator.""" + img = Image.new("RGBA", (64, 64), (82, 121, 242, 255)) + d = ImageDraw.Draw(img) + d.ellipse((20, 10, 44, 34), fill=(255, 255, 255, 255)) + d.rounded_rectangle((25, 31, 39, 48), radius=4, fill=(255, 255, 255, 255)) + d.rectangle((21, 47, 43, 52), fill=(18, 28, 58, 255)) + return img + def is_running(self) -> bool: return bool(self.started and self.icon is not None and not self._exiting) @@ -494,23 +547,15 @@ def _system_double_click_seconds() -> float: return 0.5 def _handle_tray_primary_click(self) -> bool: - """Resuelve click simple del panel y doble click de la app completa.""" + """Handle the tray primary click. + + The active desktop composition has no Quick Panel. A click must + therefore restore the normal application immediately instead of + waiting for a double-click gesture. + """ if not callable(self.on_open_quick): - if os.name != "nt": - return self.toggle_window() - - now = time.monotonic() - should_toggle = False - with self._tray_click_lock: - elapsed = now - self._last_tray_click - if 0.0 < elapsed <= self._double_click_seconds: - self._last_tray_click = 0.0 - should_toggle = True - else: - self._last_tray_click = now - - return self.toggle_window() if should_toggle else False + return self.show_window() if os.name != "nt": return self._open_quick_from_tray() diff --git a/core/dev_virtual_lights.py b/core/dev_virtual_lights.py new file mode 100644 index 0000000..edc7eee --- /dev/null +++ b/core/dev_virtual_lights.py @@ -0,0 +1,239 @@ +"""Development-only in-memory WiZ lights. + +This module is deliberately kept outside the production transport path. It +lets a developer exercise the real desktop UI with several lights without +opening a UDP socket or writing fake devices to the user's saved-bulb list. +""" +from __future__ import annotations + +import logging +import math +import os +import threading +import time +from typing import Any + +from config.paths import is_flet_build +from core.light_controller import LightController +from core.wiz_capabilities import Capabilities +from core import wiz_scenes + +_LOG = logging.getLogger(__name__) +ENV_NAME = "WIZZ_DEV_VIRTUAL_BULBS" +MAX_VIRTUAL_BULBS = 12 + + +def virtual_bulb_count_from_environment() -> int: + """Return an opt-in source-run-only simulator count, otherwise zero.""" + if is_flet_build(): + return 0 + raw = str(os.environ.get(ENV_NAME) or "").strip() + if not raw: + return 0 + try: + return max(1, min(MAX_VIRTUAL_BULBS, int(raw))) + except ValueError: + _LOG.warning("[DEV] %s must be a number; virtual lights disabled.", ENV_NAME) + return 0 + + +class VirtualLightController(LightController): + """LightController-compatible simulator with no network lifecycle.""" + + is_virtual = True + + def __init__(self, count: int = 3) -> None: + super().__init__() + count = max(1, min(MAX_VIRTUAL_BULBS, int(count))) + self.proto = None + self.running = False + self._removed_bulbs = [] + self.bulbs = {} + self.bulb_ips = set() + self._target_mode = "all" if count > 1 else "single" + self._selected_ips = set() + + for index in range(count): + ip = f"192.0.2.{10 + index}" + self.bulb_ips.add(ip) + self.bulbs[ip] = { + "ip": ip, + "mac": f"02:00:00:00:00:{index + 1:02x}", + "name": f"Virtual bulb {index + 1}", + "module": "DEV-RGBTW", + "caps": Capabilities(rgb=True, tunable_white=True, dimmable=True, kelvin_min=2200, kelvin_max=6500, model="DEV-RGBTW"), + "state": {"state": True, "dimming": 100, "temp": 4000}, + "last_seen": time.time(), + "system": {"moduleName": "DEV-RGBTW", "fwVersion": "dev"}, + "model": {}, + } + + self._active_ip = sorted(self.bulb_ips)[0] + self._selected_ips = set(self.bulb_ips) if count > 1 else {self._active_ip} + self._mirror = dict(self.bulbs[self._active_ip]["state"]) + self._target = dict(self._mirror) + self._scene_stop = threading.Event() + self._scene_thread: threading.Thread | None = None + self._animated_scene_targets: dict[str, dict[str, float | int]] = {} + self._selection_change_pending = False + _LOG.warning("[DEV] %s virtual bulbs enabled. No WiZ traffic will be sent.", count) + + def start(self) -> None: + """Do not start the inherited event loop or UDP endpoint.""" + self.running = True + if self._scene_thread is None or not self._scene_thread.is_alive(): + self._scene_stop.clear() + self._scene_thread = threading.Thread( + target=self._run_scene_animation, + daemon=True, + name="wizz-dev-virtual-scenes", + ) + self._scene_thread.start() + self._fire_callback() + + def stop(self) -> None: + self.running = False + self._scene_stop.set() + + def _save_control_config(self) -> None: + """Keep simulator selections out of the user's persisted config.""" + + def refresh(self) -> None: + self._fire_callback() + + def _fire_callback(self, *, throttle: bool = False) -> None: + """Mark target-only changes so the preview need not redraw colors.""" + if not self._selection_change_pending: + super()._fire_callback(throttle=throttle) + return + if not self._callback: + return + snapshot = self.get_state() + snapshot["_virtual_selection_only"] = True + try: + self._callback(snapshot) + except Exception: + _LOG.debug("[DEV] virtual selection callback failed", exc_info=True) + + def set_target_mode(self, mode: str) -> None: + self._selection_change_pending = True + try: + super().set_target_mode(mode) + finally: + self._selection_change_pending = False + + def set_target_selection(self, ips: list[str] | set[str] | tuple[str, ...]) -> None: + self._selection_change_pending = True + try: + super().set_target_selection(ips) + finally: + self._selection_change_pending = False + + def set_active_bulb(self, ip: str) -> None: + self._selection_change_pending = True + try: + super().set_active_bulb(ip) + finally: + self._selection_change_pending = False + + def rescan(self) -> bool: + self._fire_callback() + return True + + def get_scan_status(self) -> dict[str, Any]: + return {"in_progress": False, "found": len(self.bulbs), "error": None, "virtual": True} + + def _mark(self) -> None: + """Apply a normal LightController command to virtual target state.""" + super()._mark() + current = self.get_state() + scene_id = current.get("sceneId") + if scene_id is None: + self._animated_scene_targets.clear() + else: + current["_virtual_rgb"] = self._scene_rgb(int(scene_id), 0.0) + for ip in self._control_targets(): + info = self.bulbs.get(ip) + if info: + info["state"] = dict(current) + info["last_seen"] = time.time() + self._dirty = False + self._fire_callback() + + def set_scene(self, scene_id: int, speed: int | None = None) -> None: + """Apply a representative visual scene; dynamic scenes animate in DEV.""" + super().set_scene(scene_id, speed) + scene = wiz_scenes.get(int(scene_id)) + if scene and scene.dynamic: + scene_speed = int(speed if speed is not None else 100) + self._animated_scene_targets = { + ip: { + "scene_id": int(scene_id), + "speed": max(20, min(200, scene_speed)), + "phase": 0.0, + "updated_at": time.monotonic(), + } + for ip in self._control_targets() + } + else: + self._animated_scene_targets.clear() + + @staticmethod + def _hex_rgb(value: str) -> tuple[int, int, int]: + clean = str(value).lstrip("#") + if len(clean) != 6: + return 139, 92, 246 + return tuple(int(clean[index:index + 2], 16) for index in (0, 2, 4)) + + def _scene_rgb(self, scene_id: int, moment: float) -> tuple[int, int, int]: + scene = wiz_scenes.get(scene_id) + base = self._hex_rgb(scene.color if scene else "#8b5cf6") + if not scene or not scene.dynamic: + return base + + accents = { + 1: (0, 215, 255), 2: (255, 55, 120), 3: (255, 205, 80), + 4: (80, 120, 255), 5: (255, 170, 35), 7: (45, 205, 120), + 20: (255, 225, 245), 23: (0, 220, 255), 26: (255, 40, 210), + 27: (45, 205, 90), 29: (255, 210, 110), 31: (255, 180, 205), + } + accent = accents.get(scene_id, (255, 255, 255)) + mix = (math.sin(moment * 2.4) + 1.0) / 2.0 + return tuple(round(a + (b - a) * mix) for a, b in zip(base, accent)) + + def _run_scene_animation(self) -> None: + while not self._scene_stop.wait(0.08): + if not self.running or not self._animated_scene_targets: + continue + now = time.monotonic() + changed = False + for ip, animation in list(self._animated_scene_targets.items()): + scene_id = int(animation["scene_id"]) + speed = int(animation["speed"]) + info = self.bulbs.get(ip) + if not info or info.get("state", {}).get("sceneId") != scene_id: + self._animated_scene_targets.pop(ip, None) + continue + previous = float(animation.get("updated_at", now)) + elapsed = max(0.0, min(0.25, now - previous)) + # Higher scene speed means a quicker but still smooth fade. + animation["phase"] = float(animation.get("phase", 0.0)) + elapsed * 2.4 * (speed / 100.0) + animation["updated_at"] = now + rgb = self._scene_rgb(scene_id, float(animation["phase"])) + if info["state"].get("_virtual_rgb") != rgb: + info["state"]["_virtual_rgb"] = rgb + info["last_seen"] = time.time() + changed = True + if changed: + self._fire_callback(throttle=True) + + def reset_light(self) -> None: + super().reset_light() + self._mark() + + def get_virtual_bulbs(self) -> list[dict[str, Any]]: + """Small presentation snapshot used only by the development preview.""" + return [ + {"ip": ip, "name": info.get("name", ip), "state": dict(info.get("state") or {}), "targeted": ip in self._control_targets()} + for ip, info in sorted(self.bulbs.items()) + ] diff --git a/core/light_controller.py b/core/light_controller.py index 184472c..8d5eebc 100644 --- a/core/light_controller.py +++ b/core/light_controller.py @@ -93,6 +93,11 @@ def __init__(self, event_bus=None) -> None: control_cfg = self.config.get("control", {}) or {} self._target_mode = self._normalise_mode(control_cfg.get("mode", "single")) self._active_ip = control_cfg.get("active_ip") + raw_selected = control_cfg.get("selected_ips", []) + self._selected_ips = { + str(ip).strip() for ip in raw_selected + if isinstance(ip, str) and str(ip).strip() + } if isinstance(raw_selected, (list, tuple, set)) else set() try: self._slider_interval_ms = int(control_cfg.get("slider_interval_ms", 65)) except Exception: @@ -365,10 +370,12 @@ def get_scan_status(self) -> dict[str, Any]: } # ------------------------------------------------------------------ # - # Targeting: una ampolleta / todas + # Targeting: una ampolleta / selección rápida / todas # ------------------------------------------------------------------ # def _normalise_mode(self, mode: Any) -> str: m = str(mode or "single").lower().strip() + if m in {"selected", "selection", "seleccionadas"}: + return "selected" return "all" if m in {"all", "multi", "group", "todas"} else "single" def _save_control_config(self) -> None: @@ -377,6 +384,7 @@ def _save_control_config(self) -> None: { "mode": self._target_mode, "active_ip": self._active_ip, + "selected_ips": sorted(self._selected_ips), "slider_interval_ms": self._slider_interval_ms, }, ) @@ -458,6 +466,11 @@ def _control_targets(self) -> set[str]: ip = self._ensure_active_ip() return {ip} if ip else set() + if self._target_mode == "selected": + reachable = self._reachable_targets() + selected = self._selected_ips & (reachable or self._saved_targets()) + return selected + # Modo todas: prioriza solo dispositivos vivos/descubiertos; fallback a guardados si nada responde. return reachable or self._saved_targets() @@ -467,6 +480,29 @@ def set_target_mode(self, mode: str) -> None: self._save_control_config() self._fire_callback() + def set_target_selection(self, ips: list[str] | set[str] | tuple[str, ...]) -> None: + """Apply a transient multi-target selection without creating a group.""" + selected: set[str] = set() + for value in ips: + ip = str(value or "").strip() + try: + ipaddress.ip_address(ip) + except ValueError: + continue + if not self._is_removed_bulb(ip, self._device_mac(ip)): + selected.add(ip) + if len(selected) <= 1: + self._selected_ips = selected + if selected: + self.set_active_bulb(next(iter(selected))) + return + self._selected_ips = selected + self._target_mode = "selected" + self._active_ip = sorted(selected)[0] + self.bulb_ips.update(selected) + self._save_control_config() + self._fire_callback() + def set_active_bulb(self, ip: str) -> None: ip = (ip or "").strip() try: @@ -476,6 +512,7 @@ def set_active_bulb(self, ip: str) -> None: if self._is_removed_bulb(ip, self._device_mac(ip)): return self._active_ip = ip + self._selected_ips = {ip} self.bulb_ips.add(ip) self._target_mode = "single" self._save_control_config() @@ -496,6 +533,7 @@ def get_target_config(self) -> dict[str, Any]: return { "mode": self._target_mode, "active_ip": active, + "selected_ips": sorted(self._selected_ips), "targets": sorted(self._control_targets()), "reachable": sorted(self._reachable_targets()), "saved": sorted(self._saved_targets()), diff --git a/core/platform/__init__.py b/core/platform/__init__.py index a2b86e1..720ba6a 100644 --- a/core/platform/__init__.py +++ b/core/platform/__init__.py @@ -29,6 +29,7 @@ detect_linux_capabilities, ) from .composition import PlatformServices, build_platform_services +from .runtime import PlatformRuntime __all__ = [ "AutostartService", @@ -53,4 +54,5 @@ "detect_linux_capabilities", "PlatformServices", "build_platform_services", + "PlatformRuntime", ] diff --git a/core/platform/linux.py b/core/platform/linux.py index 37f9df3..e719a36 100644 --- a/core/platform/linux.py +++ b/core/platform/linux.py @@ -45,12 +45,13 @@ def detect_linux_capabilities() -> DesktopCapabilities: "pystray is not installed" if has_display else "no graphical desktop session" ) ) - hotkey = ( - CapabilityState.permission_required("global input permission may be required") + # The beta intentionally has no user-safe Linux global-shortcut backend. + # The ``keyboard`` package would require root access to /dev/input, which + # must never be a requirement for running the desktop app. + hotkey = CapabilityState.unavailable( + "Linux global shortcuts are disabled until a safe XDG portal backend is implemented" if session == "wayland" - else CapabilityState.degraded("backend depends on the desktop session") - if session in {"", "x11"} - else CapabilityState.unavailable("unsupported desktop session") + else "Linux global shortcuts are not implemented in this beta" ) window = ( CapabilityState.degraded("window operations depend on the compositor") diff --git a/core/platform/runtime.py b/core/platform/runtime.py new file mode 100644 index 0000000..ea645fb --- /dev/null +++ b/core/platform/runtime.py @@ -0,0 +1,37 @@ +"""Opt-in runtime facade for desktop platform services.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from .composition import PlatformServices, build_platform_services + + +@dataclass(slots=True) +class PlatformRuntime: + """Coordinate optional desktop services without starting them implicitly.""" + + services: PlatformServices + + @classmethod + def create(cls, platform: str | None = None) -> "PlatformRuntime": + return cls(build_platform_services(platform)) + + @property + def capabilities(self): + return self.services.capabilities + + def start_tray(self, menu: Sequence[object]) -> bool: + tray = self.services.tray + if tray is None: + return False + return bool(tray.start(menu)) + + def stop(self) -> None: + tray = self.services.tray + if tray is not None: + tray.stop() + + +__all__ = ["PlatformRuntime"] diff --git a/core/quick_panel_controller.py b/core/quick_panel_controller.py index bd24c4e..a273ee1 100644 --- a/core/quick_panel_controller.py +++ b/core/quick_panel_controller.py @@ -10,10 +10,10 @@ class QuickPanelController: """Coordinate Quick Panel state without owning WiZ protocol behavior.""" - QUICK_WIDTH = 440 - QUICK_HEIGHT = 680 - QUICK_MIN_WIDTH = 440 - QUICK_MIN_HEIGHT = 680 + QUICK_WIDTH = 380 + QUICK_HEIGHT = 600 + QUICK_MIN_WIDTH = 380 + QUICK_MIN_HEIGHT = 600 QUICK_MARGIN = 16 def __init__( diff --git a/core/update_checker.py b/core/update_checker.py new file mode 100644 index 0000000..ccee72e --- /dev/null +++ b/core/update_checker.py @@ -0,0 +1,63 @@ +"""Pure release-channel and version selection helpers. + +Network access and installation are intentionally left to a later phase. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any, Iterable + + +@dataclass(frozen=True, slots=True) +class ReleaseInfo: + version: str + download_url: str | None = None + prerelease: bool = False + notes_url: str | None = None + + +def _version_key(value: str) -> tuple[int, ...]: + numbers = re.findall(r"\d+", str(value)) + return tuple(int(item) for item in numbers) or (0,) + + +def parse_release(payload: dict[str, Any]) -> ReleaseInfo | None: + tag = str(payload.get("tag_name") or payload.get("name") or "").strip() + if not tag: + return None + assets = payload.get("assets") + download = None + if isinstance(assets, list): + for asset in assets: + if isinstance(asset, dict) and asset.get("browser_download_url"): + download = str(asset["browser_download_url"]) + break + return ReleaseInfo( + version=tag.lstrip("vV"), + download_url=download, + prerelease=bool(payload.get("prerelease", False)), + notes_url=str(payload.get("html_url")) if payload.get("html_url") else None, + ) + + +def select_latest_release( + payloads: Iterable[dict[str, Any]], + *, + channel: str = "stable", +) -> ReleaseInfo | None: + allow_prerelease = str(channel).strip().lower() in {"beta", "prerelease"} + releases = [] + for payload in payloads: + release = parse_release(payload) + if release is not None and (allow_prerelease or not release.prerelease): + releases.append(release) + return max(releases, key=lambda item: _version_key(item.version), default=None) + + +def is_update_available(current_version: str, release: ReleaseInfo | None) -> bool: + return release is not None and _version_key(release.version) > _version_key(current_version) + + +__all__ = ["ReleaseInfo", "is_update_available", "parse_release", "select_latest_release"] diff --git a/core/update_client.py b/core/update_client.py new file mode 100644 index 0000000..c307584 --- /dev/null +++ b/core/update_client.py @@ -0,0 +1,37 @@ +"""Read-only GitHub Releases client for the future updater UI.""" + +from __future__ import annotations + +import json +from urllib.request import Request, urlopen +from typing import Any + +from .update_checker import ReleaseInfo, select_latest_release + + +class ReleaseClient: + def __init__(self, owner: str = "yvvvl", repo: str = "WizzController", *, timeout: float = 5.0): + self.owner = owner + self.repo = repo + self.timeout = float(timeout) + + @property + def endpoint(self) -> str: + return f"https://api.github.com/repos/{self.owner}/{self.repo}/releases" + + def latest(self, *, channel: str = "stable") -> ReleaseInfo | None: + request = Request( + self.endpoint, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "WizZ-Desktop-Updater", + }, + ) + with urlopen(request, timeout=self.timeout) as response: + payload: Any = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, list): + return None + return select_latest_release(payload, channel=channel) + + +__all__ = ["ReleaseClient"] diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 6602ade..8a7f9bb 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -1,20 +1,30 @@ # WizZ Desktop — contexto maestro del proyecto -Última actualización: 2026-08-25 +Última actualización: 2026-08-28 ## Nota de auditoría del checkout actual -La rama auditada es `feature/ui-dynamic-multi-target` en el commit `4afbafd`. -La documentación arquitectónica y los reportes históricos fueron restaurados -desde el commit previo que contiene la foundation RGBIC y multiplataforma. La -suite local queda en `318 passed` con warnings de deprecación de Flet; no se -introduce soporte productivo RGBIC, scheduler ni streaming en esta fase. - -La siguiente fase activa es `feature/cross-platform-foundation-phase2`. Ya -incluye detección explícita de capacidades Linux, autostart XDG, apertura de -carpetas mediante `xdg-open` y una frontera de ventana basada en callbacks. -Estos servicios todavía no están conectados al runtime principal; el tray, -las hotkeys globales y el packaging Linux siguen pendientes de validación. +La rama de trabajo de Linux beta es `feature/v1.2.0-linux-beta`. El baseline +documentado para la planificación de release es `f158d30`, con los gates de +endurecimiento Windows completados. La versión pública estable continúa siendo +`v1.1.0`; el candidato actual es `v1.2.0` (build 2) y no debe anunciarse como +publicado hasta el tag y la release de GitHub. + +La rama ya incorpora targeting transitorio `single`/`selected`/`all`, consulta +read-only de GitHub Releases, persistencia empaquetada fuera del directorio de +la aplicación y la frontera multiplataforma aprobada. Las builds Windows +guardan los datos del usuario en `%LOCALAPPDATA%\\WizZDesktop`, incluso cuando +Flet expone `FLET_APP_STORAGE_DATA`; no se deben incluir JSON reales en el +artefacto. Las instalaciones Flet anteriores se migran desde su ubicación +legacy al primer arranque. + +Linux tiene una beta nativa validada en Ubuntu 22.04 GNOME/Wayland: wiring +productivo, paquete extraído, tray AppIndicator, XDG persistence/autostart y +control WiZ LAN. Los hotkeys globales permanecen deshabilitados +intencionalmente por seguridad. Antes de publicar, ambos artefactos deben +reconstruirse desde el mismo commit de release. macOS está diferido. RGBIC +continúa como beta cerrada experimental; no forma parte del soporte público de +v1.2.0. Este documento es la fuente principal de continuidad para nuevas sesiones de ChatGPT/Codex. Antes de modificar el proyecto, se debe leer completo y @@ -57,12 +67,26 @@ implemente su propio protocolo, discovery o controlador. - **Versión estable:** `v1.1.0`, publicada desde `main` y marcada por el tag `v1.1.0`. -- **Desarrollo principal declarado:** Quick Panel `v1.2.0`. -- **Estado de Quick Panel:** foundation y rediseño Premium implementados; - faltan validación manual con ventana/tray reales, hardware y plataformas - representativas antes de considerarlo terminado para release. -- **Checkout al crear este documento:** rama - `feature/v1.3.0-effects-engine-foundation`. +- **Desarrollo principal declarado:** consolidación del release público + `v1.2.0` (Windows estable + Linux beta) desde un commit común aprobado. +- **Estado de Quick Panel:** retirado de la composición activa de v1.2.0 tras + fallos reales de geometría y foco. La bandeja restaura únicamente la ventana + principal. Su código experimental queda fuera del flujo público hasta que + exista una implementación independiente y validada. +- **Targeting:** selección transitoria de una, varias o todas las luces + implementada y cubierta por tests; no requiere grupos persistentes. +- **Fixture DEV de múltiples luces:** `WIZZ_DEV_VIRTUAL_BULBS=<1..12>` permite + ejecutar la app desde código fuente con ampolletas RGBTW virtuales, sin UDP + ni persistencia de dispositivos falsos. Sirve para validar targeting, estado + y controles sin hardware múltiple. El repaint continuo de escenas dinámicas + en Flet 0.85.2 es irregular; no debe usarse como evidencia de fades reales. +- **Actualizaciones:** selección de canal y cliente GitHub read-only + implementados, con comprobación manual visible en Ajustes y enlace validado + a la release oficial. Todavía no existe descarga, instalación, reinicio ni + rollback automático. +- **Persistencia empaquetada:** validada en + `%LOCALAPPDATA%\\WizZDesktop`; los datos sobreviven al reemplazo del build y + la carpeta Flet legacy se migra sin borrar su origen. - **Foundation experimental adicional:** la base interna de efectos y RGBIC ya separa frame lógico, compresión lógica, calibración, steps físicos, mapper puro, simulador por segmentos y encoder puro. También existe un @@ -75,10 +99,10 @@ implemente su propio protocolo, discovery o controlador. desarrollador; ya existen fixtures sanitizados y evidencia revisada para un dispositivo, además de un validador beta local para registrar nuevos casos. Esto sigue sin equivaler a soporte productivo. -- **Cross-platform Foundation Phase 1:** la frontera neutral - `core/platform/`, sus contratos y fakes están implementados y pendientes de - revisión. No hay migración, detección de OS, adapters reales, wiring de UI - ni packaging Linux/macOS. +- **Linux beta:** contratos y servicios están conectados al runtime; Ubuntu + validó build nativa, tray AppIndicator, persistencia XDG, autostart y WiZ + LAN. Los hotkeys Wayland no están resueltos y se muestran como no + disponibles. macOS está explícitamente diferido. ### Ramas relevantes @@ -89,6 +113,8 @@ implemente su propio protocolo, discovery o controlador. | `feature/v1.2.0-quick-panel` | Foundation del Quick Panel; apunta a `1838eca` | | `feature/v1.2.0-quick-panel-design` | Rediseño Premium terminado en `ec9da58` | | `feature/v1.3.0-effects-engine-foundation` | Modelos y simulador base de efectos/RGBIC en `0a09163`; pendiente de revisión | +| `release/v1.2.0-windows` | Release candidate pública Windows; baseline planificado `f158d30` | +| `feature/cross-platform-foundation-phase2` | Foundation Linux validada en CI/VM, sin wiring productivo | | `feature/v1.1.0-pywizlight-capabilities` | Auditoría, atribución y packaging de `pywizlight` | La historia de la rama actual pasa por `ea1cc0e` (foundation del Quick Panel), @@ -152,7 +178,7 @@ UI y los servicios de aplicación. Sus responsabilidades incluyen: - ciclo asyncio en un thread dedicado; - discovery WiZ y caché de dispositivos; -- targeting `single` o `all`; +- targeting `single`, `selected` o `all`; - estado lógico reflejado hacia la UI; - control de power, brillo, RGB, Kelvin, escenas y favoritos; - coalescing del camino de escritura; @@ -238,8 +264,8 @@ expansión de alcance. ### Frontera cross-platform ADR 0004 establece un único producto con integraciones de escritorio -capability-driven. Phase 1 crea una frontera todavía desconectada en -`core/platform/`: +capability-driven. `core/platform/` contiene la frontera compartida y los +servicios Linux todavía aislados del runtime principal: - `CapabilityStatus` expresa `available`, `unavailable`, `degraded` o `permission_required`; @@ -280,8 +306,8 @@ alcance operativo actual. `hidden`, `quick` y `full`. - Mantener el menú contextual del tray y su lifecycle. - Usar una vista compacta por cards para Quick Panel. -- Mantener `Individual` y `All lights`; grupos parciales están fuera de - alcance porque exigirían cambiar el contrato de `LightController`. +- Mantener compatibilidad con `Individual` y `All lights`, añadiendo selección + parcial transitoria desde Home sin crear grupos persistentes. - Usar el sistema i18n existente para todo texto visible. - Reutilizar Color Studio en vez de construir otro picker o pipeline de color. - Capturar y restaurar todas las propiedades de ventana alteradas por el modo @@ -717,23 +743,28 @@ exacta empaquetada. ### Corto plazo -- revisar y aprobar Cross-platform Foundation Phase 1; -- congelar mediante tests el comportamiento Windows antes de encapsularlo; -- terminar la validación profesional del Quick Panel; -- realizar smoke manual completo con tray, ventana y luces físicas; -- validar experiencia, foco, espaciado, targeting y multi-monitor; -- revisar la foundation de efectos sin confundirla con una feature terminada; -- ejecutar validación end-to-end del beta validator contra el hardware - comunitario y revisar el artefacto sanitizado generado; -- decidir cierre e integración de Quick Panel v1.2. +- ejecutar el plan + `docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md`; +- estabilizar en Home la experiencia de selección transitoria + `single`/`selected`/`all`; +- integrar una notificación de actualización read-only y tolerante a fallos; +- conservar el candidato Windows validado y completar el artefacto Linux beta + de `v1.2.0` desde el mismo commit de release; +- actualizar versión, changelog y documentación pública sólo al entrar en + release candidate; +- probar el artefacto descargado desde CI antes de fusionar y etiquetar; +- crear la beta RGBIC desde la base estable `v1.2.0`, nunca mezclándola con el + cierre del release público. ### Mediano plazo -- encapsular las integraciones Windows detrás de los contratos aprobados sin - cambiar su comportamiento; -- crear fallbacks UI capability-driven en una fase explícita; -- diseñar y validar adapters Linux antes del estado beta; +- continuar el Quick Panel como ventana temporal independiente en una rama + aislada, después de validar un mecanismo nativo compatible; +- completar el smoke de Ubuntu Desktop, artefacto, checksum y documentación de + limitaciones para cerrar Linux beta; - mantener macOS diferido hasta disponer de un equipo o tester comunitario; +- diseñar descarga, verificación, instalación y rollback antes de llamar + “autoactualizador” al cliente de releases; - diseñar e implementar el Effects Engine común; - añadir contrato realtime en `LightController`; - implementar Gradient sobre el scheduler común; @@ -783,7 +814,10 @@ pertenecen a `docs/third-party/`. - AppIndicator no siempre expone un clic primario equivalente; - posicionamiento y foco dependen del compositor; - multi-monitor con DPI mixto no está validado físicamente; -- no existen grupos parciales, sólo una luz o todas; +- la selección parcial transitoria existe en Home, pero Quick Panel todavía + expone principalmente los modos una/todas; +- el panel actual comparte la ventana principal; la ventana temporal + independiente está diseñada pero diferida; - Color/White, favoritos y ON/OFF requieren prueba con hardware real. ### Effects / RGBIC @@ -823,14 +857,16 @@ pertenecen a `docs/third-party/`. ### Cross-platform -- `core/platform/` sólo contiene contratos y fakes; ningún backend existente - está conectado; +- `core/platform/` contiene contratos, fakes y servicios Linux; el runtime + principal ahora entrega sus servicios a Ajustes, usa XDG para autostart y + carpetas, y selecciona AppIndicator en GNOME/Wayland antes de iniciar tray; - `degraded` significa utilizable con limitaciones, no disponibilidad plena; - el backend productivo de hotkeys para Linux sigue sin decidirse; - tray, foco, work area y Quick Panel requieren validación X11/Wayland; - instancia única Unix todavía necesita una decisión de IPC para activación; - macOS no tiene soporte ni CI activo hasta disponer de validación real; -- no prometer Linux beta sólo por aprobar Phase 1. +- Linux sigue siendo beta hasta superar smoke real, build nativa y prueba de + control LAN en Ubuntu Desktop; no inferir ese estado sólo desde CI o tests. ### Dependencias y arquitectura diff --git a/docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md b/docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md new file mode 100644 index 0000000..c1b73d6 --- /dev/null +++ b/docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md @@ -0,0 +1,450 @@ +# WizZ Desktop v1.2 public release and RGBIC closed beta roadmap + +Status: In Progress + +Date: 2026-08-27 + +Branch at planning baseline: `release/v1.2.0-windows` + +Baseline commit: `f158d30` + +## 1. Objective + +Deliver the next version through two intentionally separate channels: + +1. a stable public Windows release plus a clearly labelled Linux beta focused + on reliable everyday control; and +2. a closed RGBIC beta for controlled validation with the community hardware + tester. + +The work must preserve the functioning WiZ control path, avoid broad UI or +platform rewrites during release stabilization, and keep experimental RGBIC +behavior out of the public stable channel. + +## 2. Product outcomes + +### Public channel: WizZ Desktop v1.2.0 + +The public release should provide: + +- stable Windows operation for existing v1.1.0 users; +- quick transient selection of one, several or all saved lights; +- persistent user configuration outside the installed/build directory; +- reliable main-window, tray, hotkey and current Quick Panel behavior; +- a polished Home targeting experience with clear single, selected and all + states; +- read-only update availability checking and a safe route to the GitHub + release page; +- a reproducible Windows artifact, checksum and release checklist. + +### Closed channel: RGBIC beta + +The beta should provide only the minimum surface required to validate the +existing experimental RGBIC foundation: + +- an explicit beta/prerelease version and artifact; +- manual calibration input for the tester's installation; +- explicit scene slot input, never a hardcoded universal `sceneId`; +- one-shot experimental sends through the approved `LightController` bridge; +- sanitized fixture/report generation; +- visible warnings that transport success is not visual confirmation; +- no scheduler, streaming, Screen Sync or public support claim. + +## 3. Scope boundaries + +### Included in the v1.2.0 public release + +- Windows stable runtime and packaging. +- Linux beta runtime, packaging and Ubuntu Desktop validation. Linux is part + of the same v1.2.0 release only after its dedicated beta gate passes. +- Transient multi-target selection already implemented in commit `93c0136`. +- Packaged Windows persistence uses `%LOCALAPPDATA%\\WizZDesktop`; a Flet + Documents-based legacy location is migrated on first launch. +- Read-only GitHub release discovery already implemented in commit `2b3bd9b`. +- Targeted Home UI improvements needed to expose and explain selection. +- Deprecation cleanup only where it is small, mechanical and covered by tests. +- Documentation, version metadata, changelog, build artifact and smoke tests. + +### Explicitly excluded from v1.2.0 + +- Production RGBIC support. +- Effects scheduler, realtime session, streaming or Screen Sync. +- Automatic download, replacement or restart of the executable. +- A new installer technology or signing pipeline. +- The independent Quick Panel native-window redesign. +- A UI-engine migration or PySide6/C++ rewrite. +- macOS implementation, CI or support claims. +- Refactors of `LightController`, `WizProtocol`, action sequences or persisted + favorites that are unrelated to a demonstrated release defect. + +### Deferred design work + +The separate temporary Quick Panel window remains an approved *future* design +in `docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md`. It is +not a v1.2.0 release gate: after real geometry and focus failures, the active +v1.2.0 composition no longer creates a Quick Panel. The tray restores only the +main window until an independent implementation is manually validated. + +## 4. Release streams and dependency order + +```text +Current clean baseline: release/v1.2.0-windows @ f158d30 + | + v + Phase A — Public scope and documentation freeze + | + v + Phase B — Home multi-target UX stabilization + | + v + Phase C — Update notification integration + | + v + Phase D — Windows release hardening + | + v + Phase E — v1.2.0 release candidate and public tag + | + +-----------+-----------+ + | | + v v + Linux beta completion RGBIC closed beta branch + from the v1.2.0 stable base +``` + +The RGBIC beta must not become a prerequisite for the public release. The +already prepared Windows candidate remains a protected checkpoint while Linux +beta work occurs on `feature/v1.2.0-linux-beta`. Final public publication waits +for both platform artifacts to be rebuilt from the same approved release commit. + +## 5. Phase A — Scope and documentation freeze + +### Tasks + +- Update `docs/codex/PROJECT_CONTEXT.md` to the actual release branch and + implemented state. +- Mark transient target selection as implemented, not future group support. +- Record packaged storage as the Flet-managed per-user data directory. +- Record the release client as read-only; it does not install updates. +- Separate Windows stable, Linux beta, macOS deferred and RGBIC closed beta. +- Preserve historical queries without rewriting their evidence. + +### Exit gate + +- Canonical context matches the checked-out code and recent commits. +- This plan is reviewed and the worktree contains documentation only. + +## 6. Phase B — Home multi-target UX stabilization + +Status: Completed on 2026-08-27. The deterministic controller/component +contracts are covered by tests. A DEV-only virtual multi-light fixture was +also added for manual source-run testing; its continuous scene-frame delivery +is not release evidence because Flet desktop repaint timing remains irregular. + +### User interaction contract + +- The destination selector is visible near the top of Home. +- One click toggles a light in or out of the transient selection. +- At least one light remains selected when saved lights exist. +- Selecting every light maps to `all`. +- Selecting one light maps to `single`. +- Selecting two or more, but not all, maps to `selected`. +- The UI communicates how many lights will receive the next action. +- Selection does not require creating, naming or editing a permanent group. +- Existing controls continue to call the same `LightController`. + +### States to design and test + +- no saved lights; +- one saved light; +- multiple saved lights, one selected; +- multiple saved lights, partial subset selected; +- all saved lights selected; +- selected light offline or removed; +- discovery refresh while a selection exists; +- narrow and normal desktop window widths; +- Spanish and English labels. + +### Tests and evidence + +- Pure/controller tests simulate at least three bulbs. +- Component tests verify selection toggling and state labels. +- Existing `tests/test_target_selection.py` remains green. +- Hardware smoke uses the available single bulb for command correctness. +- A future community/multi-device tester validates a real partial subset; this + is desirable evidence but not a blocker when simulated contracts pass. + +### Exit gate + +- No command is sent to a target outside the selected set in tests. +- Single and all behavior remains backward compatible. +- Home remains usable at the supported minimum window size. + +## 7. Phase C — Safe update notification + +Status: Completed on 2026-08-28. The Settings UI provides an explicit, +background, read-only stable-channel check and only opens the validated +official release page. No download or installation path was added. + +### v1.2.0 behavior + +- Query GitHub Releases only on explicit user action or a bounded background + check after startup. +- Compare the running version with the selected release channel. +- Stable users ignore prereleases. +- Beta users may opt into prereleases in a later beta-only setting. +- Show current version, available version and release notes link. +- Open the official GitHub release page using the platform integration + boundary. +- Network failure, timeout, malformed JSON or GitHub rate limiting must not + prevent WizZ from starting or controlling lights. + +### Not yet authorized + +- Silent background downloads. +- Replacing a running executable. +- Elevation prompts. +- Installer self-update. +- Automatic rollback. + +GitHub Releases is sufficient free hosting for the version metadata and +downloadable artifacts. Automatic installation remains a later phase after +the installer, integrity and rollback model are designed. + +### Exit gate + +- Update checks are read-only and failure-safe. +- Stable/prerelease channel tests remain deterministic without network access. +- The UI contains no hardcoded visible strings. + +## 8. Phase D — Windows release hardening + +Status: Completed on 2026-08-28. + +### Automated validation + +Run from the project virtual environment: + +```powershell +python -m pytest -q +python -m compileall -q main.py app_meta.py core config ui localization tests tools +python tools/i18n_audit.py +python tools/desktop_selftest.py +git diff --check +``` + +### Manual Windows smoke matrix + +- clean start with no existing process; +- second-instance activation; +- discovery and manual light persistence; +- one-light power, brightness, RGB, Kelvin and scene control; +- Home selection states; +- tray open, hide, restore and real exit; +- global hotkey registration and execution; +- no Quick Panel activation; the tray must restore only the main window; +- settings and favorites persistence after restart; +- packaged build reads the existing per-user configuration; +- rebuilding/replacing the application does not erase configuration; +- no real config JSON, IP, MAC, hotkey or log is packaged in the artifact. + +### Packaging validation + +- Build with `flet build windows` and the project release script. +- Inspect the generated `BUILD_INFO.json`. +- Verify the ZIP filename/version and SHA-256 file. +- Launch the extracted artifact outside the repository. +- Confirm configuration remains in `%LOCALAPPDATA%\\WizZDesktop`. +- Confirm third-party notices and required assets are present. + +### Exit gate + +- Automated suite and GitHub workflows are green. +- Manual smoke matrix has no release-blocking failure. +- Known Flet deprecation warnings are documented and do not hide new warnings. + +### Recorded Phase D checkpoint — 2026-08-28 + +- `desktop_selftest.py` confirmed the Windows native hotkey backend, tray + dependencies and the three configured hotkeys. +- An isolated build was launched and the second-instance test passed: the + duplicate process exited with code `0` while the first one remained active. +- The Windows packaged app migrated four legacy JSON files from the previous + Flet Documents location to `%LOCALAPPDATA%\\WizZDesktop\\config` on real + launch. +- The packaged log confirms configuration and logs now use the LocalAppData + location. The old Documents folder is retained as a recoverable migration + source. +- Real WiZ hardware validation against the authorized saved light completed: + power, 2700 K white at 35%, red RGB at 35%, and scene `18` were accepted, + read back through `getPilot`, and visually confirmed. +- The tested firmware rejects a combined restore payload that mixes + `state: false` with mode parameters. Restoration must set the desired mode + first, then send the final power state separately. +- The three configured global Windows hotkeys were manually confirmed with the + desktop window both visible and hidden in the tray. +- A clean copy of the Windows candidate was launched from a temporary folder + outside the repository. It found the existing saved light, used the + LocalAppData configuration, and controlled the real device successfully. +- The DEV three-light fixture was manually confirmed for two-light selection, + select-all and invert-selection. Its targeted virtual lights updated without + touching real WiZ hardware. +- Automated closeout: 28 selection/virtual/persistence tests and 25 + favorites/runtime tests passed; compilation, i18n audit and whitespace check + also passed. +- The Quick Panel was removed from the active release flow after real tray, + focus and geometry failures; it is not a Phase D smoke requirement. + +No remaining Phase D release-blocking checks. + +## 9. Phase E — v1.2.0 release candidate + +Status: Release candidate prepared on 2026-08-28; publication pending owner approval. + +### Version and public documentation + +- Change the canonical version to `1.2.0` and increment the build number. +- Keep `app_meta.py`, `pyproject.toml`, README and generated artifact metadata + consistent. +- Add a user-focused `CHANGELOG.md` entry. +- Document migration behavior and the persistent data path concept without + publishing a developer-specific absolute path. +- State clearly that RGBIC is not part of stable v1.2.0. + +### Git and release flow + +1. Keep commits small and scoped on `release/v1.2.0-windows`. +2. Push each validated checkpoint to the existing remote branch. +3. Require green CI and Windows build artifact. +4. Test the downloaded CI artifact, not only the local build. +5. Open a PR into `main` and review the complete release diff. +6. Merge without rewriting public history. +7. Create annotated tag `v1.2.0` from the approved `main` commit. +8. Publish the GitHub Release with ZIP, SHA-256 and release notes. + +### Prepared candidate checkpoint + +- Metadata is `v1.2.0` / build `2` in `app_meta.py` and `pyproject.toml`. +- The changelog and README describe the Windows release scope and exclusions. +- The official Windows output contains `BUILD_INFO.json`, notices and licenses. +- `WizZDesktop-v1.2.0-windows-x64.zip` was created with SHA-256 + `ae0489f14d010f4f5ab4ecfd8d9c1a6132fca0e364dfb2af62192605d1ee0ed6`. +- ZIP inspection found no configuration JSON, saved device addresses, hotkeys + or logs. +- The packaged executable logged `v1.2.0 (build 2)`, used LocalAppData, + detected the saved light and passed the second-instance smoke test. + +The release ZIP is ready locally. Do not create the public tag or GitHub +Release until the owner explicitly approves publication. + +### Linux beta gate added on 2026-08-28 + +- The packaged app starts on Ubuntu Desktop using native Flet Linux output. +- Shared controls, discovery and local WiZ control pass a real LAN smoke test. +- GNOME/Wayland tray uses AppIndicator when available; if unavailable, the app + remains usable with normal window close behavior and an explicit capability + report. +- Settings opens data/log folders through `xdg-open` and manages a user XDG + autostart entry. +- The build stores configuration under the packaged Flet/XDG user storage, + never under the installed artifact directory. +- Global hotkeys, exact window placement and second-instance foreground + activation are documented as desktop/compositor-dependent beta limitations. +- A Linux archive and SHA-256 are produced from the same approved v1.2.0 + release commit as the refreshed Windows archive. + +### Definition of done + +- The public artifact launches and controls a real WiZ bulb. +- Existing per-user data survives replacement of the application directory. +- The release page and checksum are available. +- `main`, the tag and both platform artifacts identify the same version. +- Stable update checks select `v1.2.0` and ignore RGBIC prereleases. + +## 10. RGBIC closed beta after v1.2.0 + +### Branch and channel + +- Create `beta/v1.3.0-rgbic` from the approved `v1.2.0` tag. +- Integrate reviewed RGBIC foundation changes through explicit commits or a + PR; do not merge an unknown historical branch wholesale. +- Use a prerelease version such as `v1.3.0-beta.1`. +- Publish it as a GitHub prerelease or distribute the artifact only to the + named tester. +- Stable channel selection must ignore this prerelease. + +### Beta validation order + +1. Re-run pure model, compression, mapping, encoder and fixture tests. +2. Confirm `physical_segments=17` for the tester's current installation. +3. Require manual `sceneId` input and record firmware/model metadata. +4. Send one controlled pattern through the existing experimental bridge. +5. Ask the tester for visual confirmation separately from transport success. +6. Generate and review a sanitized artifact before it is returned or shared. +7. Compare an alternate slot only when the protocol procedure calls for it. +8. Stop after the planned slot/pattern; no automatic sequence or streaming. + +### Beta stop conditions + +- unexpected device state or saved-scene activation; +- firmware/model mismatch not represented in the fixture; +- payload rejection or inconsistent visual result; +- any need to hardcode a universal `sceneId`; +- any request to bypass `LightController` or open a second UDP path; +- any artifact containing tester IP, MAC or other unsanitized identifiers. + +### Promotion criteria + +RGBIC remains beta until multiple fixtures and firmware/device combinations +support a stable capability, calibration and slot-selection policy. One +successful tester session is evidence, not a production support guarantee. + +## 11. Linux beta implementation + +Linux is now an in-progress v1.2.0 workstream on `feature/v1.2.0-linux-beta`: + +- preserve the validated platform contracts and fakes; +- select the compatible AppIndicator tray backend before pystray is imported; +- wire XDG autostart and `xdg-open` through the existing platform boundary; +- degrade hotkeys honestly on Wayland when permissions/backend are absent; +- retain Unix single-instance exclusion while documenting that foreground + activation is not yet portable; +- create a Linux package only after tray, window and persistence behavior are + tested as an installed application. + +macOS stays deferred until a real machine or accountable tester is available. + +## 12. Risk register + +| Risk | Effect | Mitigation | +| --- | --- | --- | +| Broad UI refactor during release | Regressions and delayed delivery | Limit v1.2 to Home targeting and release defects | +| Same-window Quick Panel limitations | Geometry/focus surprises | Stabilize current behavior; continue redesign separately | +| Only one local bulb | Partial selection lacks physical proof | Use deterministic multi-device fakes; seek later tester smoke | +| GitHub/network unavailable | Slow or blocked startup | Bounded, optional, failure-safe update check | +| Configuration bundled or overwritten | User data loss | Use Flet user storage and test replacement explicitly | +| RGBIC prerelease leaks into stable | Unsafe experimental exposure | Separate branch, prerelease flag and stable channel filter | +| Transport success treated as visual success | False validation | Require independent tester observation in every fixture | +| Linux behavior inferred from CI | Unsupported desktop claim | Require GNOME/Wayland/X11 VM evidence before beta packaging | + +## 13. Checkpoint discipline + +For every implementation block: + +1. begin from a clean worktree; +2. identify the exact files and protected boundaries; +3. add or update focused tests; +4. run focused tests before the full suite; +5. run compile, i18n and `git diff --check` as applicable; +6. perform the required manual smoke before claiming completion; +7. inspect `git status` and `git diff --stat`; +8. create one descriptive commit; +9. push the checkpoint so notebook and PC stay synchronized; +10. record final evidence in a query when the phase closes. + +Never stage real files from `config/json`, logs, tester fixtures containing +identifiers, `.venv`, `build` or `dist` without inspecting them explicitly. + +## 14. Immediate next block + +Begin Phase E: prepare v1.2.0 metadata, changelog and release material. diff --git a/docs/codex/queries/2026-08-27-01-linux-desktop-integration-phase1.md b/docs/codex/queries/2026-08-27-01-linux-desktop-integration-phase1.md index 0320dc1..7133184 100644 --- a/docs/codex/queries/2026-08-27-01-linux-desktop-integration-phase1.md +++ b/docs/codex/queries/2026-08-27-01-linux-desktop-integration-phase1.md @@ -15,10 +15,15 @@ the Windows runtime, UI and WiZ core unchanged. - Unknown platforms return unavailable capabilities instead of silently using a Linux or Windows implementation. - Added deterministic tests for Linux selection and unsupported platforms. +- Added opt-in `PlatformRuntime` facade; it never starts services implicitly + and delegates tray lifecycle only when explicitly requested. +- Exposed the runtime snapshot from `main.py` without starting any new + platform service or changing existing Windows behavior. ## Deliberate limits -- The composition helper is not wired into `main.py` yet. +- Services remain unwired; only the immutable capability snapshot is exposed + from `main.py`. - No Windows behavior was moved or replaced. - No macOS adapter is provided; macOS remains deferred. - Tray execution still requires the separately validated foreground run-loop. diff --git a/docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md b/docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md new file mode 100644 index 0000000..9751cc7 --- /dev/null +++ b/docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md @@ -0,0 +1,39 @@ +# Quick Panel window redesign + +Date: 2026-08-27 +Status: Design approved, implementation pending + +## Goal + +Replace the current same-window mode switch with a compact temporary window +that can be opened from the tray without moving or resizing the main window. + +## Required behavior + +- The Quick Panel is a separate, frameless, always-on-top utility window. +- It opens above the taskbar at the lower-right of the active work area. +- It contains only fast actions: target, power, brightness, color/white mode + and a short favorites list. +- Clicking outside the panel hides it. +- Losing focus hides it. +- Closing the panel never stops WizZ or the main window. +- Tray activation toggles the panel; an explicit “Open full window” action + restores/focuses the main application. +- The panel and main window share state through the existing controller; no + duplicate WiZ transport or LightController is created. + +## Constraints + +- Preserve all existing actions and translations. +- Keep Windows behavior stable while the new window lifecycle is validated. +- Provide a safe fallback when a platform cannot create a second window. +- Do not modify `LightController` or `WizProtocol`. + +## Implementation sequence + +1. Identify the supported Flet second-window mechanism for version 0.85.2. +2. Add a window-service contract for temporary utility windows. +3. Implement a fake lifecycle and focus-loss tests first. +4. Implement the Windows adapter behind an opt-in flag. +5. Validate open, outside-click hide, focus-loss hide and full-window restore. +6. Only then connect the tray action and remove the same-window mode switch. diff --git a/docs/codex/queries/2026-08-27-03-quick-target-selection.md b/docs/codex/queries/2026-08-27-03-quick-target-selection.md new file mode 100644 index 0000000..5a46770 --- /dev/null +++ b/docs/codex/queries/2026-08-27-03-quick-target-selection.md @@ -0,0 +1,36 @@ +# Quick target selection + +Date: 2026-08-27 +Status: Completed + +## Decision + +WizZ uses a transient multi-target selection instead of requiring users to +create and edit named groups. Home can select any subset of discovered lights; +actions are sent only to that subset. Existing `single` and `all` modes remain +compatible. + +## Model + +- `single`: one active IP; +- `selected`: two or more selected IPs, persisted as `selected_ips` only as + the last UI selection; +- `all`: every reachable/saved target. + +No duplicate controller or transport is created. The selection is simulated in +tests with multiple targets and validated against the controller's target set. + +## Implemented + +- `LightController` persists and resolves `single`, `selected` and `all`. +- `TargetSelector` sends a transient subset through the existing controller. +- `tests/test_target_selection.py` covers single, partial and all target sets. +- Commit `93c0136` contains the scoped implementation. + +The remaining work is presentation and usability stabilization in Home, not a +missing targeting contract. + +## Release scope + +This behavior belongs in the public Windows release. RGBIC remains isolated to +the closed beta channel. diff --git a/docs/codex/queries/2026-08-27-04-home-targeting-dev-virtual-bulbs.md b/docs/codex/queries/2026-08-27-04-home-targeting-dev-virtual-bulbs.md new file mode 100644 index 0000000..5f6de66 --- /dev/null +++ b/docs/codex/queries/2026-08-27-04-home-targeting-dev-virtual-bulbs.md @@ -0,0 +1,50 @@ +# Home targeting and DEV virtual bulbs + +Status: Completed with a documented DEV-only visual limitation + +Date: 2026-08-27 + +Related roadmap: `docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md`, Phase B. + +## Objective + +Make the Home destination selector communicate one, partial and all-light +selection clearly, then provide a safe development fixture for exercising the +same controller contract without multiple physical WiZ bulbs. + +## Delivered + +- The Home selector preserves and displays `single`, `selected` and `all` + targeting, including a concise selected-count label. +- Partial selections are restored from `selected_ips`; selecting every saved + light maps to `all` without creating a persistent group. +- A source-run-only `WIZZ_DEV_VIRTUAL_BULBS=` mode creates one to + twelve virtual RGBTW bulbs using `VirtualLightController`. +- The simulator has no UDP endpoint, no discovery task and does not persist + its bulbs or target choices into the user's WiZ configuration. +- The Home-only DEV preview displays virtual bulb state, selection, brightness + and representative static/dynamic scene colours. + +## Known limitation + +The simulator is valid for deterministic selection, targeting, static colour, +brightness, power, favorites and scene-command tests. On Flet 0.85.2, visual +delivery of continuous background scene frames can be irregular in a running +desktop client: a later user interaction may reveal the latest frame. This is +not evidence about real WiZ dynamic scenes and is not used as release +validation for fades. It remains a DEV-only fixture and is excluded from the +public packaged build. + +## Validation executed + +- `python -m pytest -q`: 356 passed, 98 existing deprecation warnings. +- `python -m compileall -q main.py app_meta.py core config ui localization tests tools`: exit 0. +- `python tools/i18n_audit.py`: 590 keys in English and Spanish; no potential + hardcoded UI strings. +- `git diff --check`: clean. + +## Next step + +Phase C: integrate the already-tested read-only GitHub release client into a +failure-safe Settings UI. It must only show availability and open the official +release page; it must not download, install or replace the application. diff --git a/docs/codex/queries/2026-08-28-05-safe-update-notification.md b/docs/codex/queries/2026-08-28-05-safe-update-notification.md new file mode 100644 index 0000000..2aa6bb7 --- /dev/null +++ b/docs/codex/queries/2026-08-28-05-safe-update-notification.md @@ -0,0 +1,42 @@ +# Safe update notification integration + +Status: Completed + +Date: 2026-08-28 + +Related roadmap: `docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md`, Phase C. + +## Scope + +Integrated the existing read-only GitHub Releases client into the Settings +About card. The user explicitly starts the check; it runs outside the UI +thread and reports a recoverable result. + +## Behaviour + +- Shows the running version and a manual **Check for updates** action. +- Reads stable GitHub Releases only; prereleases remain excluded from the + public channel. +- Shows current, available, no-release and temporary-service-unavailable + states without blocking light control. +- Offers **Open release** only for an HTTPS URL under the official + `github.com/yvvvl/WizzController/releases` path. +- Does not download, install, replace, restart or elevate the application. + +## UI correction + +The initial card used an expanded Column inside a wrapping Row. Flet 0.85.2 +rendered that combination as a grey ErrorWidget. It was replaced with bounded +`ResponsiveRow` columns and verified manually in the desktop app. + +## Validation + +- Focused update and layout tests pass. +- Manual Windows check confirmed the Settings card renders normally. +- Full project validation is recorded in the release checkpoint commit. + +## Next step + +Phase D: Windows release hardening: desktop self-test, packaged artifact +inspection, clean-extraction launch, data persistence verification and release +material preparation. diff --git a/docs/codex/queries/2026-08-28-06-remove-quick-panel-from-v1-2.md b/docs/codex/queries/2026-08-28-06-remove-quick-panel-from-v1-2.md new file mode 100644 index 0000000..a264d18 --- /dev/null +++ b/docs/codex/queries/2026-08-28-06-remove-quick-panel-from-v1-2.md @@ -0,0 +1,35 @@ +# Remove Quick Panel from v1.2 active desktop flow + +Status: Completed + +Date: 2026-08-28 + +## Decision + +The Quick Panel is removed from the active v1.2.0 desktop composition. It was +visually and behaviorally unreliable as a reused native Flet window: tray +activation could restore the compact layout instead of the main application. + +## Implemented boundary + +- `main.py` no longer constructs `QuickPanelController` or `QuickPanelView`. +- WiZ state callbacks now update only the visible main application. +- `TrayService` receives no Quick Panel callbacks in the public composition. +- The primary tray action now restores the main window immediately on Windows, + Linux and other supported systems. +- The Quick Panel menu actions are absent from the public tray menu. + +The experimental controller and view are deliberately retained in source and +their tests remain intact. They are not reachable from the shipped app and can +only return through a separate, validated design decision. + +## Validation + +- Tray restore, historical Quick Panel isolation and instance-activation tests: + 48 passed. +- Python compilation and whitespace validation completed without errors. + +## Follow-up + +Continue Phase D Windows release hardening using the simpler one-window tray +flow. Do not include Quick Panel behavior in the v1.2.0 release checklist. diff --git a/docs/codex/queries/2026-08-28-07-windows-real-wiz-validation.md b/docs/codex/queries/2026-08-28-07-windows-real-wiz-validation.md new file mode 100644 index 0000000..cb96f2f --- /dev/null +++ b/docs/codex/queries/2026-08-28-07-windows-real-wiz-validation.md @@ -0,0 +1,48 @@ +# Windows candidate: real WiZ validation + +Status: Completed + +Date: 2026-08-28 + +Related plan: `docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md`, Phase D. + +## Scope + +Validate the public Windows candidate against an authorized, real WiZ light on +the local network. This was not an RGBIC test and did not use streaming, +Screen Sync or experimental RGBIC payloads. + +## Read-only findings + +- The device responded to `getPilot`, `getSystemConfig` and `getModelConfig`. +- It reports firmware `1.38.0`, a 2200–6500 K CCT range and RGBTW output. +- The baseline state was captured before any visible command. + +## Executed validation + +Each command received a successful protocol response and its state was read +back through `getPilot`: + +- white: 2700 K at 35%; +- RGB: red at 35%; +- built-in scene: `sceneId` 18; +- final restoration to the original off state, 2200 K and 100% brightness. + +The tester visually confirmed the white, red and scene transitions. + +## Firmware behavior recorded + +A combined restore payload containing `state: false` plus color or scene +parameters returned `Invalid params`. A safe restoration sequence is: + +1. apply the desired white/RGB/scene mode and brightness; +2. send the final `state: false` command separately. + +This is a hardware-specific protocol observation. It does not justify a +production RGBIC claim and does not change the existing RGBIC architecture. + +## Result + +The Windows release candidate has real-device evidence for ordinary one-light +power, CCT, RGB and built-in scene control. Remaining Phase D work is desktop +behavior and persistence validation, not basic WiZ protocol reachability. diff --git a/docs/codex/queries/2026-08-28-08-v1-2-release-candidate-artifact.md b/docs/codex/queries/2026-08-28-08-v1-2-release-candidate-artifact.md new file mode 100644 index 0000000..81c13bd --- /dev/null +++ b/docs/codex/queries/2026-08-28-08-v1-2-release-candidate-artifact.md @@ -0,0 +1,42 @@ +# v1.2.0 Windows release candidate artifact + +Status: Ready for owner approval + +Date: 2026-08-28 + +## Candidate + +- Product: WizZ Desktop +- Version: `1.2.0` +- Build: `2` +- Commit: `b081e2cd163d` +- Platform: Windows x64 + +## Local release material + +- ZIP: `dist/release/WizZDesktop-v1.2.0-windows-x64.zip` +- SHA-256: `ae0489f14d010f4f5ab4ecfd8d9c1a6132fca0e364dfb2af62192605d1ee0ed6` +- Manifest: `dist/windows/BUILD_INFO.json` + +## Evidence + +- The release build logged `v1.2.0 (build 2)` on launch. +- It used `%LOCALAPPDATA%\\WizZDesktop` for config and logs. +- It detected the existing saved WiZ light. +- Its second execution exited successfully while the primary process remained + active. +- The ZIP includes its executable, `BUILD_INFO.json`, third-party notices and + licenses. +- ZIP inspection found no real configuration, device, hotkey or log files. + +## Scope statement for public release notes + +v1.2.0 improves Windows reliability, temporary multi-light selection, +persistence and manual update discovery. It does **not** provide RGBIC stable +support, Screen Sync, streaming, automatic installation or an active Quick +Panel. + +## Publication boundary + +The artifact is ready locally only. Creating `v1.2.0`, merging into `main`, +or publishing a GitHub Release requires explicit owner approval. diff --git a/docs/codex/queries/2026-08-28-09-linux-v1-2-beta-delivery.md b/docs/codex/queries/2026-08-28-09-linux-v1-2-beta-delivery.md new file mode 100644 index 0000000..acfb671 --- /dev/null +++ b/docs/codex/queries/2026-08-28-09-linux-v1-2-beta-delivery.md @@ -0,0 +1,87 @@ +# Linux v1.2.0 beta delivery + +Status: Validated; publication pending + +Date: 2026-08-28 + +## Objective + +Extend the already prepared v1.2.0 Windows candidate with a Linux beta. The +release remains one product and one WiZ core: Windows is stable; Linux is +explicitly beta; macOS stays deferred. + +## Existing foundation + +- The shared WiZ core, Flet UI, persistence model and automated tests already + run on Ubuntu Desktop. +- Linux capability contracts, XDG autostart, `xdg-open`, window callbacks and + a pystray adapter exist under `core/platform`. +- Earlier Ubuntu GNOME/Wayland validation showed that pystray's AppIndicator + backend provides a visible, interactive tray menu, while another backend can + expose an inert icon. + +## Implementation decisions + +- The normal tray service selects `PYSTRAY_BACKEND=appindicator` only on Linux + GNOME/Ubuntu or Wayland and only when the user did not select another backend. +- AppIndicator runs its own worker loop on Linux. `run_detached()` is not used + there because Flet does not provide the GObject loop it requires; a detached + icon can report as started while remaining invisible. +- Settings uses the platform system service to open data and log folders; the + Linux fallback is `xdg-open`. +- Packaged Linux builds store configuration in the XDG configuration directory + (`~/.config/WizZDesktop` by default) and logs in the XDG state directory + (`~/.local/state/WizZDesktop` by default). `XDG_CONFIG_HOME` and + `XDG_STATE_HOME` are honored, while older Flet storage remains a migration + source. This avoids desktop-environment-specific paths. +- The existing persisted `startup_with_windows` key is retained for v1.1 data + compatibility, but on Linux it means start at login and creates/removes the + user's XDG autostart entry. +- Linux does not claim exact window placement under Wayland. This is governed + by the compositor, not by the app. +- Global hotkeys are intentionally disabled for the Linux beta. The legacy + `keyboard` backend requires root access to Linux input devices and is not a + safe desktop integration. The app neither asks the user to run with `sudo` + nor attempts registration; the disabled Hotkeys destination explains that a + future XDG GlobalShortcuts portal backend is required. +- The tray menu also omits hotkey re-registration on Linux while that feature + is unavailable, so it cannot imply that a root-only workaround exists. +- The disabled Hotkeys status uses a muted, non-interactive rail indicator and + exposes the reason in a hover tooltip; it is deliberately not a navigation + destination, so clicking it cannot cause selection flicker or open an + unusable editor. +- The existing file-lock single-instance exclusion is retained for beta. It + prevents duplicate controllers even where foreground activation is not yet + portable. +- The packaged app has its own Python runtime. Therefore Linux tray support + explicitly bundles `PyGObject==3.48.2` and `pycairo==1.26.1`; the build + checks for the native `gi` extension before it creates a release archive. + +## Validation executed + +1. Ubuntu Desktop automated suite: `368 passed, 98 warnings`. +2. i18n audit: `608` `en`/`es` keys and no hardcoded UI strings. +3. Native Flet Linux build completed and generated the compressed archive plus + its SHA-256 sidecar. +4. The extracted package was launched outside the repository on Ubuntu 22.04 + GNOME/Wayland. +5. AppIndicator tray, hide/restore, real exit, XDG persistence and Linux + autostart were manually tested. +6. Discovery and controlled power, brightness, RGB, Kelvin and built-in-scene + commands were sent successfully to the authorized WiZ light `192.168.1.5`. +7. Packaged tray support was repaired by bundling `PyGObject==3.48.2` and + `pycairo==1.26.1`; the generated artifact verifies the native `gi` module. + +## Remaining publication work + +- Copy the measured Linux SHA-256 value into the final release record. +- Rebuild Windows and Linux from the same approved release commit before + creating a public tag or GitHub Release. + +## Out of scope + +- New WiZ protocol behavior, RGBIC product support, Screen Sync, streaming or + scheduler work. +- A Quick Panel, forced window placement, or a Linux promise for global + hotkeys on Wayland. Running WizZ with `sudo` is not a supported workaround. +- macOS support. diff --git a/docs/codex/queries/2026-08-28-10-v1-2-release-preparation.md b/docs/codex/queries/2026-08-28-10-v1-2-release-preparation.md new file mode 100644 index 0000000..87f1068 --- /dev/null +++ b/docs/codex/queries/2026-08-28-10-v1-2-release-preparation.md @@ -0,0 +1,59 @@ +# v1.2.0 release preparation + +Status: Ready for owner approval after final artifact checksums + +Date: 2026-08-28 + +## Release channels + +- **Windows x64:** stable public release. +- **Linux x64:** clearly labelled Ubuntu Desktop beta in the same GitHub + release. +- **RGBIC:** excluded from v1.2.0; it remains a separate closed beta after the + public release is approved. + +## Included in v1.2.0 + +- Temporary selection of one, several or all saved WiZ lights, without + maintaining persistent groups. +- Read-only update check that opens the official GitHub Release page; it never + downloads, replaces or restarts the app automatically. +- More reliable tray restoration, real tray exit and single-instance handling. +- Persistent packaged settings and logs: `%LOCALAPPDATA%\\WizZDesktop` on + Windows; XDG configuration and state directories on Linux. +- Native Linux beta package for Ubuntu Desktop with AppIndicator tray, + XDG autostart, `xdg-open` integration and an intentional no-hotkeys state. + +## Explicit exclusions + +- RGBIC production support, streaming, Screen Sync and an effects scheduler. +- Automatic updater installation or rollback. +- Quick Panel; the experimental window was removed from the public v1.2 flow. +- macOS support. + +## Evidence recorded + +- Automated validation: `368 passed, 98 warnings` on Windows and Ubuntu + Desktop. +- Localization audit: `608` keys in `en`/`es`, no hardcoded UI strings. +- Windows: real WiZ LAN validation for power, brightness, RGB, Kelvin and a + built-in scene; packaging, AppData migration, tray and single-instance smoke. +- Linux: native package built and launched outside the repository on Ubuntu + 22.04 GNOME/Wayland; AppIndicator tray, XDG persistence and WiZ LAN control + were manually verified. +- GTK/Flutter shutdown warnings observed only after normal exit are upstream + runtime cleanup warnings, not WizZ failures. + +## Assets to attach + +1. `WizZDesktop-v1.2.0-windows-x64.zip` +2. `WizZDesktop-v1.2.0-windows-x64.zip.sha256` +3. `WizZDesktop-v1.2.0-linux-x64.tar.gz` +4. `WizZDesktop-v1.2.0-linux-x64.tar.gz.sha256` + +## Publication gate + +The public tag and GitHub Release remain intentionally uncreated. Before +publication, merge the approved Linux changes into the chosen release branch, +rebuild both platform artifacts from that same final commit, record both +checksums, review the release diff, and obtain owner approval. diff --git a/localization/catalogs/en.py b/localization/catalogs/en.py index 0e9bcaf..0a31bd1 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -3,6 +3,22 @@ CATALOG: dict[str, str] = { # Application / navigation "app.name": "WizZ Desktop", + "updates.title": "UPDATES", + "updates.idle": "Running {version}. Check manually; WizZ never installs updates automatically.", + "updates.check": "Check for updates", + "updates.checking": "Checking the official release page…", + "updates.available": "Version {version} is available.", + "updates.current": "You are up to date ({version}).", + "updates.no_release": "No stable release is available yet.", + "updates.unavailable": "The update service is unavailable. Try again later.", + "updates.open_release": "Open release", + "updates.open_failed": "Could not open the official release page.", + "dev.virtual.title": "DEV · VIRTUAL BULBS", + "dev.virtual.no_network": "No WiZ network traffic", + "dev.virtual.hint": "Use this preview to verify targeting, color and brightness without hardware. Select a bulb here to target it alone.", + "dev.virtual.state": "{power} · {brightness}%", + "dev.virtual.on": "On", + "dev.virtual.off": "Off", "nav.home": "Home", "nav.color": "Color", "nav.scenes": "Scenes", @@ -171,6 +187,7 @@ "runtime.close_to_tray": "Close to tray", "runtime.open_minimized": "Open minimized", "runtime.start_with_windows": "Start with Windows", + "runtime.start_at_login": "Start at login", "runtime.saved": "Background settings saved.", # About @@ -183,12 +200,17 @@ # Settings language integration 'language.selector': 'Idioma / Language', 'language.effective': 'Effective language: {language}', - 'language.detected': 'Windows language: {language}', + 'language.detected': 'System language: {language}', 'settings.title': 'Settings', 'settings.subtitle': 'Targets, discovery and light management', 'settings.target.section': 'TARGET', 'settings.target.mode': 'Control mode', 'settings.target.help': 'The target only determines which IP receives commands.', + 'target.mode.selected': 'Selection', + 'target.selection.none': 'No saved lights', + 'target.selection.single': '1 light selected', + 'target.selection.partial': '{selected} of {total} selected', + 'target.selection.all': 'All {total} selected', 'settings.slider_performance': 'Slider performance', 'settings.slider.35': '35 ms · ultra fast / more CPU', 'settings.slider.65': '65 ms · recommended', @@ -201,6 +223,7 @@ 'runtime.close_to_tray.description': 'The X hides the window instead of exiting.', 'runtime.open_minimized.description': 'Start hidden when the tray is enabled.', 'runtime.start_with_windows.description': 'Add or remove automatic startup.', + 'runtime.start_at_login.description': 'Add or remove automatic startup for this user.', 'runtime.restart_hint': 'Tray changes work best after restarting the app. Development mode does not force window restoration.', 'about.storage_hint': 'In the packaged app, settings and logs live in persistent application storage and survive updates.', 'common.information': 'Information', @@ -269,10 +292,11 @@ "bulbs.offline_detail": "○ no response", "bulbs.ip_example": "192.168.1.20", "runtime.startup.windows_only": "Start with Windows is only available on Windows.", - "runtime.startup.update_error": "Could not update Start with Windows: {error}", - "runtime.startup.updated": "Start with Windows updated.", - "runtime.startup.disabled": "Start with Windows disabled.", - "instance.already_running": "{app} is already running. Check the Windows notification area.", + "runtime.startup.linux_unavailable": "Automatic startup is unavailable on this Linux desktop.", + "runtime.startup.update_error": "Could not update automatic startup: {error}", + "runtime.startup.updated": "Automatic startup updated.", + "runtime.startup.disabled": "Automatic startup disabled.", + "instance.already_running": "{app} is already running. Check the system notification area.", "scenes.title": "Scenes", "scenes.subtitle": "WiZ scenes and local custom scenes", @@ -491,6 +515,11 @@ "hotkeys.dependency.native_ready": "Native Windows hotkeys + keyboard recording are available", "hotkeys.dependency.keyboard_ready": "keyboard is available", "hotkeys.dependency.keyboard_missing": "keyboard is not available: {error}", + "hotkeys.dependency.linux_unavailable": "Global shortcuts are disabled in Linux beta so WizZ never requires administrator permissions.", + "hotkeys.linux_disabled_title": "Hotkeys are unavailable in Linux beta", + "hotkeys.linux_disabled_body": "Wayland protects global keyboard input. WizZ does not run with sudo or capture system input. The editor will return when a safe desktop-portal integration is available.", + "hotkeys.linux_disabled_tooltip": "Hotkeys are disabled: Linux beta does not yet have a safe global-shortcut backend.", + "hotkeys.linux_disabled_short": "Unavailable", "hotkeys.validation.empty": "The shortcut is empty.", "hotkeys.validation.reserved": "{combo} is reserved by the system or too dangerous.", "hotkeys.validation.main_key": "A main key is required in addition to ctrl/alt/shift/win.", diff --git a/localization/catalogs/es.py b/localization/catalogs/es.py index f51f1ea..9eec67a 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -3,6 +3,22 @@ CATALOG: dict[str, str] = { # Aplicación / navegación "app.name": "WizZ Desktop", + "updates.title": "ACTUALIZACIONES", + "updates.idle": "Ejecutando {version}. Revisa manualmente; WizZ nunca instala actualizaciones automáticamente.", + "updates.check": "Buscar actualización", + "updates.checking": "Consultando la página oficial de lanzamientos…", + "updates.available": "La versión {version} está disponible.", + "updates.current": "Ya tienes la versión actual ({version}).", + "updates.no_release": "Aún no hay una versión estable disponible.", + "updates.unavailable": "El servicio de actualizaciones no está disponible. Inténtalo más tarde.", + "updates.open_release": "Abrir lanzamiento", + "updates.open_failed": "No se pudo abrir la página oficial del lanzamiento.", + "dev.virtual.title": "DEV · AMPOLLETAS VIRTUALES", + "dev.virtual.no_network": "Sin tráfico WiZ de red", + "dev.virtual.hint": "Usa esta vista para verificar selección, color y brillo sin hardware. Toca una ampolleta para seleccionarla sola.", + "dev.virtual.state": "{power} · {brightness}%", + "dev.virtual.on": "Encendida", + "dev.virtual.off": "Apagada", "nav.home": "Inicio", "nav.color": "Color", "nav.scenes": "Escenas", @@ -170,6 +186,7 @@ "runtime.close_to_tray": "Cerrar a bandeja", "runtime.open_minimized": "Abrir minimizado", "runtime.start_with_windows": "Iniciar con Windows", + "runtime.start_at_login": "Iniciar al iniciar sesión", "runtime.saved": "Configuración de segundo plano guardada.", # Acerca de @@ -182,12 +199,17 @@ # Settings language integration 'language.selector': 'Idioma / Language', 'language.effective': 'Idioma efectivo: {language}', - 'language.detected': 'Idioma de Windows: {language}', + 'language.detected': 'Idioma del sistema: {language}', 'settings.title': 'Ajustes', 'settings.subtitle': 'Destino, discovery y gestión de ampolletas', 'settings.target.section': 'DESTINO', 'settings.target.mode': 'Modo de control', 'settings.target.help': 'El destino solo determina a qué IP se envían los comandos.', + 'target.mode.selected': 'Selección', + 'target.selection.none': 'Sin ampolletas guardadas', + 'target.selection.single': '1 ampolleta seleccionada', + 'target.selection.partial': '{selected} de {total} seleccionadas', + 'target.selection.all': 'Todas las {total} seleccionadas', 'settings.slider_performance': 'Rendimiento de sliders', 'settings.slider.35': '35 ms · ultra rápido / más CPU', 'settings.slider.65': '65 ms · recomendado', @@ -200,6 +222,7 @@ 'runtime.close_to_tray.description': 'La X oculta la ventana en vez de salir.', 'runtime.open_minimized.description': 'Inicia oculto cuando la bandeja está activa.', 'runtime.start_with_windows.description': 'Registra o quita el inicio automático.', + 'runtime.start_at_login.description': 'Registra o quita el inicio automático para este usuario.', 'runtime.restart_hint': 'Los cambios de bandeja se aplican mejor al reiniciar la app. El modo desarrollo no fuerza restaurar ventanas.', 'about.storage_hint': 'En la app empaquetada, configuraciones y logs viven en el almacenamiento persistente y se conservan entre actualizaciones.', 'common.information': 'Información', @@ -268,10 +291,11 @@ "bulbs.offline_detail": "○ sin respuesta", "bulbs.ip_example": "192.168.1.20", "runtime.startup.windows_only": "Inicio con Windows solo está disponible en Windows.", - "runtime.startup.update_error": "No se pudo modificar Inicio con Windows: {error}", - "runtime.startup.updated": "Inicio con Windows actualizado.", - "runtime.startup.disabled": "Inicio con Windows desactivado.", - "instance.already_running": "{app} ya se está ejecutando. Revisa el área de notificación de Windows.", + "runtime.startup.linux_unavailable": "El inicio automático no está disponible en este escritorio Linux.", + "runtime.startup.update_error": "No se pudo modificar el inicio automático: {error}", + "runtime.startup.updated": "Inicio automático actualizado.", + "runtime.startup.disabled": "Inicio automático desactivado.", + "instance.already_running": "{app} ya se está ejecutando. Revisa el área de notificación del sistema.", "scenes.title": "Escenas", "scenes.subtitle": "Escenas WiZ y escenas personalizadas locales", @@ -490,6 +514,11 @@ "hotkeys.dependency.native_ready": "Hotkeys nativos de Windows + grabación con keyboard disponibles", "hotkeys.dependency.keyboard_ready": "keyboard disponible", "hotkeys.dependency.keyboard_missing": "keyboard no disponible: {error}", + "hotkeys.dependency.linux_unavailable": "Los atajos globales están desactivados en Linux beta para no pedir permisos de administrador.", + "hotkeys.linux_disabled_title": "Hotkeys no disponibles en Linux beta", + "hotkeys.linux_disabled_body": "Wayland protege el teclado global. WizZ no se ejecuta con sudo ni captura entradas del sistema. El editor quedará disponible cuando exista una integración segura mediante el portal de escritorio.", + "hotkeys.linux_disabled_tooltip": "Hotkeys desactivadas: Linux beta aún no tiene un backend seguro para atajos globales.", + "hotkeys.linux_disabled_short": "No disponible", "hotkeys.validation.empty": "El atajo está vacío.", "hotkeys.validation.reserved": "{combo} está reservado por el sistema o es demasiado peligroso.", "hotkeys.validation.main_key": "Falta una tecla principal además de ctrl/alt/shift/win.", diff --git a/localization/ui.py b/localization/ui.py index caa5443..2c52b53 100644 --- a/localization/ui.py +++ b/localization/ui.py @@ -15,7 +15,7 @@ # Labels intentionally remain bilingual/native so the user can always recover # the selector even after choosing an unfamiliar interface language. _LANGUAGE_CHOICES: tuple[tuple[str, str], ...] = ( - (LANGUAGE_SYSTEM, "Automático — Windows / Automatic — Windows"), + (LANGUAGE_SYSTEM, "Automático / Automatic"), (LANGUAGE_SPANISH, "Español"), (LANGUAGE_ENGLISH, "English"), ) diff --git a/main.py b/main.py index 3cc7762..91a902f 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import atexit +import asyncio import os import sys import time @@ -12,18 +13,18 @@ from app_meta import APP_ID, APP_NAME, display_version from core.light_controller import LightController +from core.dev_virtual_lights import VirtualLightController, virtual_bulb_count_from_environment from ui.app import WizzApp from ui.theme import Theme from config.app_runtime_manager import AppRuntimeManager from config.hotkeys_manager import HotkeysManager from core.background.tray_service import TrayService, install_window_handlers -from core.quick_panel_controller import QuickPanelController from core.single_instance import SingleInstanceGuard from core.windows_window import restore_window from core.logging_setup import configure_logging +from core.platform import PlatformRuntime from config.paths import assets_dir, config_dir, logs_dir from localization import RuntimeLanguagePreference, get_manager -from ui.quick_panel_view import QuickPanelView _APP_TITLE = APP_NAME _INSTANCE_GUARD = SingleInstanceGuard(APP_ID) @@ -34,16 +35,10 @@ configure_logging() -def _update_runtime_views( - app: Any, - quick_panel: Any, - state: dict[str, Any], -) -> None: - """Fan one LightController state out to both views.""" +def _update_runtime_view(app: Any, state: dict[str, Any]) -> None: + """Deliver one LightController state to the visible main application.""" - snapshot = dict(state or {}) - _safe(app.update_ui, dict(snapshot)) - _safe(quick_panel.update_state, dict(snapshot)) + _safe(app.update_ui, dict(state or {})) @@ -66,6 +61,23 @@ def apply_state() -> None: except Exception: pass + # Flet 0.85 owns the live session loop internally. ``run_task`` is the + # supported cross-thread bridge; relying only on the legacy ``page.loop`` + # attribute can queue a change that becomes visible only on the next click. + run_task = getattr(page, "run_task", None) + if callable(run_task): + async def apply_on_page_loop() -> None: + apply_state() + await asyncio.sleep(0) + + try: + run_task(apply_on_page_loop) + return True + except (RuntimeError, TypeError): + pass + except Exception: + return False + loop = getattr(page, "loop", None) if loop is None: @@ -101,10 +113,20 @@ def main(page: ft.Page): page.window.min_height = 540 runtime = AppRuntimeManager() + platform_runtime = PlatformRuntime.create() + # Capability snapshot is exposed for future UI integration; no + # platform service is started from this bootstrap step. + page._wizz_platform_runtime = platform_runtime i18n = get_manager() i18n.set_preference(RuntimeLanguagePreference(runtime).load()) - wiz = LightController() + virtual_bulbs = virtual_bulb_count_from_environment() + wiz = VirtualLightController(virtual_bulbs) if virtual_bulbs else LightController() + if virtual_bulbs: + logging.warning( + "[DEV] Simulador de %s ampolletas virtuales activo; no se enviará tráfico WiZ.", + virtual_bulbs, + ) hotkeys = HotkeysManager(wiz, i18n=i18n) logging.info("[Hotkeys] %s", hotkeys.backend_status()) @@ -121,20 +143,12 @@ def shutdown_services(): _safe(hotkeys.stop) _safe(wiz.stop) - app = WizzApp(page, wiz, hotkeys_manager=hotkeys) - content_host = ft.Container(content=app, expand=True) - quick_controller = QuickPanelController( + app = WizzApp( page, wiz, - app, - content_host, - ) - quick_view = QuickPanelView( - quick_controller, - wiz, - i18n=i18n, + hotkeys_manager=hotkeys, + platform_services=platform_runtime.services, ) - quick_controller.attach_view(quick_view) page.on_resize = app.handle_page_resize # PHASE32_RUNTIME_TRAY_SAFE tray = None @@ -148,8 +162,6 @@ def shutdown_services(): hotkeys_manager=hotkeys, on_shutdown=shutdown_services, i18n=i18n, - on_open_quick=quick_controller.toggle_quick, - on_open_full=quick_controller.open_full, ) tray_started = tray.start() if tray_started: @@ -193,22 +205,17 @@ def takeover_stale_instance() -> None: page._wizz_runtime = runtime page._wizz_tray = tray if tray_started else None page._wizz_hotkeys = hotkeys - page._wizz_quick_panel = quick_controller except Exception: pass wiz.set_callback( lambda state: _dispatch_wiz_state( page, - lambda snapshot: _update_runtime_views( - app, - quick_controller, - snapshot, - ), + lambda snapshot: _update_runtime_view(app, snapshot), state, ) ) - page.add(content_host) + page.add(app) page.update() # PHASE32_OPEN_MINIMIZED_SAFE try: diff --git a/pyproject.toml b/pyproject.toml index 8b6bcd2..ab0e498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wizz-controller" -version = "1.1.0" +version = "1.2.0" description = "Control local de ampolletas WiZ por LAN." readme = "README.md" requires-python = ">=3.11,<3.14" @@ -15,18 +15,23 @@ dependencies = [ "pystray>=0.19.5", "Pillow>=10.0.0", "certifi==2026.6.17", + "pycairo==1.26.1; sys_platform == 'linux'", + "PyGObject==3.48.2; sys_platform == 'linux'", ] [tool.flet] product = "WizZ Desktop" company = "yvvvl" copyright = "Copyright © 2026 Ignacio" -build_number = 1 +build_number = 2 org = "io.github.yvvvl" [tool.flet.windows] artifact = "WizZDesktop" +[tool.flet.linux] +artifact = "WizZDesktop" + [tool.flet.app] path = "." module = "main" diff --git a/requirements.txt b/requirements.txt index aa35082..4e8dc88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,7 @@ keyboard>=0.13.5 pystray>=0.19.5 Pillow>=10.0.0 certifi==2026.6.17 + +# Bandeja Linux (AppIndicator): no se instala en Windows. +pycairo==1.26.1; sys_platform == "linux" +PyGObject==3.48.2; sys_platform == "linux" diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh new file mode 100755 index 0000000..20c299a --- /dev/null +++ b/scripts/build_linux.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Build the native Linux beta artifact from a Linux desktop or WSL. +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_DIR="${ROOT}/dist/linux" +RELEASE_DIR="${ROOT}/dist/release" +CLEAN=false +SKIP_TESTS=false + +usage() { + cat <<'EOF' +Usage: ./scripts/build_linux.sh [--clean] [--skip-tests] [--output DIR] + +Builds WizZ Desktop for Linux. Run it on Linux or WSL after installing the +system dependencies required by Flet/Flutter. It produces a tar.gz archive and +a SHA-256 checksum under dist/release. The archive includes install.sh, which +installs a launcher for the current user without sudo. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --clean) CLEAN=true ;; + --skip-tests) SKIP_TESTS=true ;; + --output) + OUTPUT_DIR="${2:?--output requires a directory}" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "This build must run on Linux or WSL." >&2 + exit 1 +fi + +cd "$ROOT" +PYTHON="${PYTHON:-$ROOT/.venv/bin/python}" +if [[ ! -x "$PYTHON" ]]; then + PYTHON="$(command -v python3)" +fi +FLET="${FLET:-$ROOT/.venv/bin/flet}" +if [[ ! -x "$FLET" ]]; then + FLET="$(command -v flet || true)" +fi +if [[ -z "$FLET" || ! -x "$FLET" ]]; then + echo "Flet CLI was not found. Install flet[cli] in the active environment." >&2 + exit 1 +fi + +# Flutter native assets resolve the linker beside the selected clang binary. +# Ubuntu splits it into the lld package; without it Flet fails much later with +# an unhelpful empty tool-name error from dart_build. +CLANG_PATH="$(readlink -f "$(command -v clang || true)")" +if [[ -z "$CLANG_PATH" || ! -x "$CLANG_PATH" ]]; then + echo "clang was not found. Install: sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev" >&2 + exit 1 +fi +LLVM_BIN="$(dirname "$CLANG_PATH")" +if [[ ! -x "$LLVM_BIN/ld.lld" && ! -x "$LLVM_BIN/ld" ]]; then + echo "LLVM linker was not found beside clang at $LLVM_BIN." >&2 + echo "On Ubuntu install it with: sudo apt install -y lld-14" >&2 + exit 1 +fi + +if ! "$PYTHON" - <<'PY' +import gi +gi.require_version("AyatanaAppIndicator3", "0.1") +from gi.repository import AyatanaAppIndicator3 # noqa: F401 +print("Linux tray build dependency: PyGObject/AppIndicator OK") +PY +then + echo "Linux tray dependencies are missing from the build environment." >&2 + echo "Install PyGObject 3.48.2, pycairo 1.26.1 and the Ayatana AppIndicator typelib." >&2 + exit 1 +fi + +export PYTHONUTF8=1 +export PYTHONIOENCODING=utf-8 +export FLET_CLI_NO_RICH_OUTPUT=true + +VERSION="$($PYTHON -c 'from app_meta import APP_VERSION; print(APP_VERSION)')" +BUILD_NUMBER="$($PYTHON -c 'from app_meta import APP_BUILD_NUMBER; print(APP_BUILD_NUMBER)')" +ARTIFACT="$($PYTHON -c 'from app_meta import APP_ARTIFACT; print(APP_ARTIFACT)')" +PYTHON_VERSION="$($PYTHON -c 'import platform; print(platform.python_version())')" +FLET_VERSION="$($PYTHON -c 'import flet; print(flet.__version__)')" + +if [[ "$CLEAN" == true ]]; then + rm -rf "$ROOT/build" "$OUTPUT_DIR" +fi + +if [[ "$SKIP_TESTS" != true ]]; then + "$PYTHON" -m compileall -q main.py app_meta.py core config ui localization tests tools + "$PYTHON" -m pytest -q + "$PYTHON" tools/i18n_audit.py +fi + +mkdir -p "$RELEASE_DIR" +"$FLET" build linux . --output "$OUTPUT_DIR" --yes --no-rich-output + +BUNDLED_GI="$(find "$OUTPUT_DIR" -type f -path '*/gi/_gi*.so' -print -quit)" +if [[ -z "$BUNDLED_GI" ]]; then + echo "Build completed without bundled PyGObject; Linux tray would be unavailable." >&2 + exit 1 +fi + +EXECUTABLE="$(find "$OUTPUT_DIR" -type f -name "$ARTIFACT" -perm -u+x -print -quit)" +if [[ -z "$EXECUTABLE" ]]; then + echo "Build completed without finding executable $ARTIFACT." >&2 + exit 1 +fi + +# The release bundle is portable but also includes a first-class per-user +# installer. Keep the launcher icon explicit so the menu entry does not rely +# on the desktop guessing where Flet stored application assets. +mkdir -p "$OUTPUT_DIR/assets" +if [[ ! -f "$OUTPUT_DIR/assets/icon.png" ]]; then + cp "$ROOT/assets/icon.png" "$OUTPUT_DIR/assets/icon.png" +fi +install -m 755 "$ROOT/scripts/linux_install.sh" "$OUTPUT_DIR/install.sh" +install -m 755 "$ROOT/scripts/linux_uninstall.sh" "$OUTPUT_DIR/uninstall.sh" + +COMMIT="$(git rev-parse --short=12 HEAD 2>/dev/null || echo no-git)" +DIRTY=false +if ! git diff --quiet 2>/dev/null; then + DIRTY=true +fi + +cat > "$OUTPUT_DIR/BUILD_INFO.json" < "$CHECKSUM" + +echo "Linux beta build ready: $OUTPUT_DIR" +echo "Archive: $ARCHIVE" +echo "Checksum: $CHECKSUM" diff --git a/scripts/linux_install.sh b/scripts/linux_install.sh new file mode 100755 index 0000000..5305e82 --- /dev/null +++ b/scripts/linux_install.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Install one extracted WizZ Desktop Linux bundle for the current user. +set -euo pipefail + +PRODUCT="WizZ Desktop" +ARTIFACT="WizZDesktop" +DESKTOP_ID="io.github.yvvvl.wizz-controller.desktop" +BUNDLE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +INSTALL_DIR="$DATA_HOME/WizZDesktop" +APPLICATIONS_DIR="$DATA_HOME/applications" +DESKTOP_FILE="$APPLICATIONS_DIR/$DESKTOP_ID" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + cat <<'EOF' +Usage: ./install.sh + +Installs WizZ Desktop for the current user. It creates an entry in the +application menu and does not require sudo. Configuration and logs are kept +outside the installed bundle and are preserved when the app is updated. +EOF + exit 0 +fi + +if [[ ! -x "$BUNDLE_DIR/$ARTIFACT" ]]; then + echo "Cannot find executable $ARTIFACT beside this installer." >&2 + exit 1 +fi + +if [[ "$BUNDLE_DIR" == "$INSTALL_DIR" ]]; then + echo "$PRODUCT is already installed at $INSTALL_DIR." + echo "Run install.sh from a newly extracted release to update it." + exit 0 +fi + +if [[ -e "$INSTALL_DIR" && ! -d "$INSTALL_DIR" ]]; then + echo "Install path exists but is not a directory: $INSTALL_DIR" >&2 + exit 1 +fi + +mkdir -p "$DATA_HOME" "$APPLICATIONS_DIR" +# Replacing only this known per-user directory is safe and keeps the separate +# XDG configuration/state folders untouched. +rm -rf -- "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +cp -a "$BUNDLE_DIR/." "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/$ARTIFACT" "$INSTALL_DIR/install.sh" "$INSTALL_DIR/uninstall.sh" + +ICON_PATH="$INSTALL_DIR/assets/icon.png" +if [[ ! -f "$ICON_PATH" ]]; then + echo "The bundled application icon is missing: $ICON_PATH" >&2 + exit 1 +fi + +cat > "$DESKTOP_FILE" </dev/null 2>&1; then + update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true +fi + +echo "$PRODUCT installed. Open it from Applications or run: $INSTALL_DIR/$ARTIFACT" +echo "To remove the launcher and installed files, run: $INSTALL_DIR/uninstall.sh" diff --git a/scripts/linux_uninstall.sh b/scripts/linux_uninstall.sh new file mode 100755 index 0000000..b9087e8 --- /dev/null +++ b/scripts/linux_uninstall.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Remove the WizZ Desktop launcher and installed bundle for the current user. +set -euo pipefail + +PRODUCT="WizZ Desktop" +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +INSTALL_DIR="$DATA_HOME/WizZDesktop" +APPLICATIONS_DIR="$DATA_HOME/applications" +DESKTOP_FILE="$APPLICATIONS_DIR/io.github.yvvvl.wizz-controller.desktop" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + cat <<'EOF' +Usage: ./uninstall.sh + +Removes the current user's WizZ Desktop launcher and installed application +files. It deliberately keeps your WiZ configuration, favourites and logs. +EOF + exit 0 +fi + +rm -f -- "$DESKTOP_FILE" +if [[ -d "$INSTALL_DIR" ]]; then + rm -rf -- "$INSTALL_DIR" +fi + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true +fi + +echo "$PRODUCT was removed. Your configuration and logs were kept." diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index 77e23a9..b87542a 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -21,20 +21,26 @@ def test_config_dir_override_is_exact(monkeypatch, tmp_path): assert target.is_dir() -def test_flet_storage_migrates_legacy_json_once(monkeypatch, tmp_path): +def test_linux_packaged_build_uses_xdg_and_migrates_flet_storage(monkeypatch, tmp_path): storage = tmp_path / "app-data" - legacy = tmp_path / "legacy" - legacy.mkdir() + legacy = storage / "config" + legacy.mkdir(parents=True) (legacy / "hotkeys.json").write_text(json.dumps({"enabled": True}), encoding="utf-8") (legacy / "hotkeys.example.json").write_text("{}", encoding="utf-8") + xdg_config = tmp_path / "xdg-config" + xdg_state = tmp_path / "xdg-state" monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) monkeypatch.setenv("FLET_APP_STORAGE_DATA", str(storage)) - monkeypatch.setenv("WIZZ_LEGACY_CONFIG_DIR", str(legacy)) + monkeypatch.delenv("WIZZ_LEGACY_CONFIG_DIR", raising=False) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config)) + monkeypatch.setenv("XDG_STATE_HOME", str(xdg_state)) + monkeypatch.setattr(paths.sys, "frozen", True, raising=False) + monkeypatch.setattr(paths.sys, "platform", "linux") _reset_paths() target = paths.config_dir() - assert target == (storage / "config").resolve() + assert target == (xdg_config / paths.APP_ARTIFACT / "config").resolve() assert json.loads((target / "hotkeys.json").read_text(encoding="utf-8")) == {"enabled": True} assert not (target / "hotkeys.example.json").exists() @@ -43,6 +49,111 @@ def test_flet_storage_migrates_legacy_json_once(monkeypatch, tmp_path): _reset_paths() assert paths.config_dir() == target assert json.loads((target / "hotkeys.json").read_text(encoding="utf-8")) == {"enabled": False} + assert paths.logs_dir() == (xdg_state / paths.APP_ARTIFACT / "logs").resolve() + + +def test_windows_frozen_build_uses_local_appdata(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + legacy = tmp_path / "legacy" + legacy.mkdir() + (legacy / "config.json").write_text("{\"language\": \"es\"}", encoding="utf-8") + + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setenv("WIZZ_LEGACY_CONFIG_DIR", str(legacy)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "frozen", True, raising=False) + _reset_paths() + + target = paths.config_dir() + assert target == (local_app_data / paths.APP_ARTIFACT / "config").resolve() + assert (target / "config.json").exists() + + +def test_windows_packaged_flet_runtime_uses_local_appdata_and_migrates(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + flet_storage = tmp_path / "DocumentsFlet" + legacy_config = flet_storage / "config" + legacy_config.mkdir(parents=True) + (legacy_config / "favorites.json").write_text("[]", encoding="utf-8") + + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.setenv("FLET_APP_STORAGE_DATA", str(flet_storage)) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "frozen", True, raising=False) + _reset_paths() + + assert paths.config_dir() == (local_app_data / paths.APP_ARTIFACT / "config").resolve() + assert (paths.config_dir() / "favorites.json").exists() + assert paths.logs_dir() == (local_app_data / paths.APP_ARTIFACT / "logs").resolve() + + +def test_windows_packaged_executable_is_detected_without_frozen_flag(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "executable", str(tmp_path / "WizZDesktop.exe")) + monkeypatch.setattr(paths.sys, "frozen", False, raising=False) + _reset_paths() + + assert paths.is_flet_build() + assert paths.config_dir() == (local_app_data / paths.APP_ARTIFACT / "config").resolve() + + +def test_windows_embedded_runtime_detects_neighboring_app_executable(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + runtime_dir = tmp_path / "build" / "windows" + runtime_dir.mkdir(parents=True) + (runtime_dir / "WizZDesktop.exe").write_bytes(b"marker") + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "executable", str(runtime_dir / "python.exe")) + monkeypatch.setattr(paths.sys, "frozen", False, raising=False) + _reset_paths() + + assert paths.is_flet_build() + assert paths.config_dir() == (local_app_data / paths.APP_ARTIFACT / "config").resolve() + + +def test_windows_launcher_argument_detects_packaged_runtime(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "executable", str(tmp_path / "python.exe")) + monkeypatch.setattr(paths.sys, "argv", [str(tmp_path / "WizZDesktop.exe")]) + monkeypatch.setattr(paths.sys, "frozen", False, raising=False) + _reset_paths() + + assert paths.is_flet_build() + assert paths.config_dir() == (local_app_data / paths.APP_ARTIFACT / "config").resolve() + + +def test_flet_embedded_app_zip_detects_packaged_runtime(monkeypatch, tmp_path): + local_app_data = tmp_path / "LocalAppData" + monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) + monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) + monkeypatch.setattr(paths.sys, "platform", "win32") + monkeypatch.setattr(paths.sys, "executable", str(tmp_path / "python.exe")) + monkeypatch.setattr(paths.sys, "argv", ["main.py"]) + monkeypatch.setattr(paths.sys, "frozen", False, raising=False) + monkeypatch.setattr( + paths, + "__file__", + str(tmp_path / "flutter_assets" / "app.zip" / "config" / "paths.pyc"), + ) + _reset_paths() + + assert paths.is_flet_build() + assert paths.config_dir() == (local_app_data / paths.APP_ARTIFACT / "config").resolve() def test_json_manager_uses_override_and_atomic_save(monkeypatch, tmp_path): diff --git a/tests/test_app_runtime_manager.py b/tests/test_app_runtime_manager.py index d68dbd1..f389e3f 100644 --- a/tests/test_app_runtime_manager.py +++ b/tests/test_app_runtime_manager.py @@ -29,3 +29,55 @@ def test_runtime_keeps_only_supported_keys(tmp_path): assert "old_feature_toggle" not in saved assert data["tray_enabled"] is False assert data["open_minimized"] is True + + +def _runtime_manager_for_test(tmp_path): + import threading + + manager = AppRuntimeManager.__new__(AppRuntimeManager) + manager.base_dir = tmp_path + manager.json_dir = tmp_path + manager.path = tmp_path / "app_runtime.json" + manager._lock = threading.RLock() + manager.data = dict(AppRuntimeManager.DEFAULTS) + return manager + + +def test_linux_startup_uses_xdg_service_and_preserves_legacy_preference(monkeypatch, tmp_path): + import config.app_runtime_manager as runtime_module + + manager = _runtime_manager_for_test(tmp_path) + monkeypatch.setattr(runtime_module.sys, "platform", "linux") + + calls = [] + + class _LinuxAutostart: + def set_enabled(self, enabled): + calls.append(enabled) + return True + + monkeypatch.setattr(manager, "_linux_autostart_service", lambda: _LinuxAutostart()) + + ok, _message = manager.set_startup_with_windows(True) + + assert ok is True + assert calls == [True] + assert manager.data["startup_with_windows"] is True + + +def test_linux_startup_does_not_claim_enabled_when_xdg_entry_fails(monkeypatch, tmp_path): + import config.app_runtime_manager as runtime_module + + manager = _runtime_manager_for_test(tmp_path) + monkeypatch.setattr(runtime_module.sys, "platform", "linux") + + class _UnavailableAutostart: + def set_enabled(self, enabled): + return False + + monkeypatch.setattr(manager, "_linux_autostart_service", lambda: _UnavailableAutostart()) + + ok, _message = manager.set_startup_with_windows(True) + + assert ok is False + assert manager.data["startup_with_windows"] is False diff --git a/tests/test_dev_virtual_lights.py b/tests/test_dev_virtual_lights.py new file mode 100644 index 0000000..d3a374a --- /dev/null +++ b/tests/test_dev_virtual_lights.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from core.dev_virtual_lights import ENV_NAME, VirtualLightController, virtual_bulb_count_from_environment + + +def test_virtual_controller_is_in_memory_and_targets_selected_bulbs(): + wiz = VirtualLightController(3) + + assert wiz.proto is None + assert not wiz.thread.is_alive() + assert len(wiz.get_bulbs_detailed()) == 3 + + wiz.set_target_selection(["192.0.2.10", "192.0.2.12"]) + wiz.set_rgb(255, 0, 0) + wiz.set_brightness(45) + + bulbs = {item["ip"]: item for item in wiz.get_virtual_bulbs()} + assert bulbs["192.0.2.10"]["state"]["dimming"] == 45 + assert bulbs["192.0.2.12"]["state"]["dimming"] == 45 + assert bulbs["192.0.2.11"]["state"]["dimming"] == 100 + + +def test_virtual_controller_changes_only_active_bulb_in_single_mode(): + wiz = VirtualLightController(3) + wiz.set_active_bulb("192.0.2.11") + wiz.turn_off() + + bulbs = {item["ip"]: item for item in wiz.get_virtual_bulbs()} + assert bulbs["192.0.2.11"]["state"]["state"] is False + assert bulbs["192.0.2.10"]["state"]["state"] is True + + +def test_virtual_bulbs_require_explicit_environment_opt_in(monkeypatch): + monkeypatch.delenv(ENV_NAME, raising=False) + assert virtual_bulb_count_from_environment() == 0 + monkeypatch.setenv(ENV_NAME, "99") + assert virtual_bulb_count_from_environment() == 12 + + +def test_dynamic_scene_has_a_changing_visual_frame_without_network(): + wiz = VirtualLightController(2) + wiz.set_scene(4, speed=100) + + first = wiz._scene_rgb(4, 1.0) + later = wiz._scene_rgb(4, 1.6) + bulbs = wiz.get_virtual_bulbs() + + assert first != later + assert all(item["state"]["_virtual_rgb"] for item in bulbs) + + +def test_selecting_another_bulb_does_not_change_an_existing_scene_frame(): + wiz = VirtualLightController(2) + wiz.set_target_selection(["192.0.2.10"]) + wiz.set_scene(4, speed=180) + before = wiz.get_virtual_bulbs()[0]["state"]["_virtual_rgb"] + + wiz.set_active_bulb("192.0.2.11") + after = wiz.get_virtual_bulbs()[0]["state"]["_virtual_rgb"] + + assert before == after + + +def test_selection_callback_is_marked_so_the_preview_can_preserve_its_colours(): + wiz = VirtualLightController(2) + snapshots = [] + wiz.set_callback(snapshots.append) + + wiz.set_active_bulb("192.0.2.11") + + assert snapshots[-1]["_virtual_selection_only"] is True diff --git a/tests/test_full_app_i18n.py b/tests/test_full_app_i18n.py index 858057c..cef9e22 100644 --- a/tests/test_full_app_i18n.py +++ b/tests/test_full_app_i18n.py @@ -67,7 +67,10 @@ def isolated_config(tmp_path, monkeypatch): monkeypatch.setattr(app_paths, "_INITIALIZED_DIRS", set()) -def test_remaining_primary_panels_render_english() -> None: +def test_remaining_primary_panels_render_english(monkeypatch) -> None: + # The editor contract is platform-independent; emulate a supported + # desktop backend when this suite runs on Linux CI. + monkeypatch.setattr("config.hotkeys_manager.sys.platform", "win32") i18n = LocalizationManager(preference="en") wiz = FakeWiz() hotkeys = HotkeysManager(wiz, auto_apply=False, i18n=i18n) @@ -84,7 +87,8 @@ def test_remaining_primary_panels_render_english() -> None: assert "Toggle power" in hotkeys_text -def test_remaining_panels_update_after_language_change() -> None: +def test_remaining_panels_update_after_language_change(monkeypatch) -> None: + monkeypatch.setattr("config.hotkeys_manager.sys.platform", "win32") i18n = LocalizationManager(preference="es") wiz = FakeWiz() hotkeys = HotkeysManager(wiz, auto_apply=False, i18n=i18n) @@ -162,7 +166,8 @@ def test_tray_status_and_target_are_localized() -> None: assert tray._target_label() == "Target: 1 light · 192.168.1.20" -def test_standalone_panels_keep_spanish_compatibility() -> None: +def test_standalone_panels_keep_spanish_compatibility(monkeypatch) -> None: + monkeypatch.setattr("config.hotkeys_manager.sys.platform", "win32") wiz = FakeWiz() hotkeys = HotkeysManager(wiz, auto_apply=False) diff --git a/tests/test_hotkeys_manager.py b/tests/test_hotkeys_manager.py index bb2e2ff..da429a9 100644 --- a/tests/test_hotkeys_manager.py +++ b/tests/test_hotkeys_manager.py @@ -41,6 +41,22 @@ def test_validate_rejects_system_hotkeys(): assert HotkeysManager.validate_hotkey("ctrl+alt+a")[0] is True +def test_linux_does_not_attempt_unsafe_global_keyboard_hooks(tmp_path, monkeypatch): + _temp_json(monkeypatch, tmp_path) + import config.hotkeys_manager as hotkeys_module + + keyboard = _FakeKeyboard() + monkeypatch.setattr(hotkeys_module.sys, "platform", "linux") + monkeypatch.setattr(hotkeys_module, "_keyboard", keyboard) + + manager = HotkeysManager(FakeWiz(), auto_apply=False) + manager.apply_hooks() + + assert not manager.available + assert not keyboard.added + assert "Linux" in (manager.last_error or "") + + def test_assign_replaces_conflict_without_keyboard_hooks(tmp_path, monkeypatch): _temp_json(monkeypatch, tmp_path) manager = HotkeysManager(FakeWiz(), auto_apply=False) @@ -132,7 +148,7 @@ def test_partial_native_registration_uses_keyboard_only_for_failed(tmp_path, mon _temp_json(monkeypatch, tmp_path) import config.hotkeys_manager as hotkeys_module - monkeypatch.setattr(hotkeys_module.os, "name", "nt") + monkeypatch.setattr(hotkeys_module.sys, "platform", "win32") fake_keyboard = _FakeKeyboard() monkeypatch.setattr(hotkeys_module, "_keyboard", fake_keyboard) @@ -162,7 +178,9 @@ def test_reregister_removes_old_keyboard_handles(tmp_path, monkeypatch): _temp_json(monkeypatch, tmp_path) import config.hotkeys_manager as hotkeys_module - monkeypatch.setattr(hotkeys_module.os, "name", "posix") + # Exercise the legacy keyboard fallback without pretending a real Linux + # session supports unsafe global input hooks. + monkeypatch.setattr(hotkeys_module.sys, "platform", "darwin") fake_keyboard = _FakeKeyboard() monkeypatch.setattr(hotkeys_module, "_keyboard", fake_keyboard) diff --git a/tests/test_language_ui.py b/tests/test_language_ui.py index 03b9257..50fb8b4 100644 --- a/tests/test_language_ui.py +++ b/tests/test_language_ui.py @@ -18,7 +18,7 @@ def test_language_selector_exposes_system_and_manual_modes() -> None: LANGUAGE_ENGLISH, ) labels = dict(language_choices()) - assert "Windows" in labels[LANGUAGE_SYSTEM] + assert labels[LANGUAGE_SYSTEM] == "Automático / Automatic" assert labels[LANGUAGE_SPANISH] == "Español" assert labels[LANGUAGE_ENGLISH] == "English" diff --git a/tests/test_main_instance_activation.py b/tests/test_main_instance_activation.py index 8508c03..14e7a30 100644 --- a/tests/test_main_instance_activation.py +++ b/tests/test_main_instance_activation.py @@ -72,6 +72,7 @@ def test_stale_instance_is_replaced_after_window_timeout(monkeypatch): monkeypatch.setattr(app_main, "_INSTANCE_GUARD", guard) monkeypatch.setattr(app_main.os, "name", "nt") + monkeypatch.setattr(app_main.sys, "platform", "win32") monkeypatch.setattr(app_main.time, "monotonic", lambda: next(ticks)) monkeypatch.setattr(app_main.time, "sleep", lambda _seconds: None) monkeypatch.setattr( diff --git a/tests/test_packaged_startup.py b/tests/test_packaged_startup.py index 61bc789..053c7c9 100644 --- a/tests/test_packaged_startup.py +++ b/tests/test_packaged_startup.py @@ -39,7 +39,7 @@ def _runtime_manager_for_test(tmp_path): def test_startup_flag_is_saved_only_after_registry_success(monkeypatch, tmp_path): manager = _runtime_manager_for_test(tmp_path) - monkeypatch.setattr(runtime_module.os, "name", "nt") + monkeypatch.setattr(runtime_module.sys, "platform", "win32") monkeypatch.setattr(manager, "_startup_command", lambda: '"C:\\Apps\\WizZDesktop.exe"') written = [] @@ -54,7 +54,7 @@ def test_startup_flag_is_saved_only_after_registry_success(monkeypatch, tmp_path def test_startup_flag_is_not_enabled_when_registry_write_fails(monkeypatch, tmp_path): manager = _runtime_manager_for_test(tmp_path) - monkeypatch.setattr(runtime_module.os, "name", "nt") + monkeypatch.setattr(runtime_module.sys, "platform", "win32") monkeypatch.setattr(manager, "_startup_command", lambda: '"C:\\Apps\\WizZDesktop.exe"') def fail(_command): diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index ca39577..16cfc63 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -1,3 +1,5 @@ +import importlib +import os from pathlib import Path from core.platform.capabilities import CapabilityStatus, CapabilityState, DesktopCapabilities @@ -8,16 +10,20 @@ LinuxWindowService, detect_linux_capabilities, ) +from core.background.tray_service import TrayService def test_linux_capabilities_are_explicit_and_do_not_touch_network(monkeypatch): monkeypatch.setattr("core.platform.linux.sys.platform", "linux") monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + # Capability detection must be deterministic: this is a simulated + # graphical session, independent of the CI runner's installed packages. + monkeypatch.setattr("core.platform.linux.find_spec", lambda _name: object()) caps = detect_linux_capabilities() assert caps.tray.status is CapabilityStatus.AVAILABLE - assert caps.hotkey_registration.status is CapabilityStatus.PERMISSION_REQUIRED + assert caps.hotkey_registration.status is CapabilityStatus.UNAVAILABLE assert caps.start_at_login.status is CapabilityStatus.AVAILABLE assert caps.single_instance_activation.status is CapabilityStatus.UNAVAILABLE @@ -175,3 +181,65 @@ def factory(menu): assert created[0].started assert not backend.running assert backend.icon is None + + +def test_tray_service_prefers_appindicator_on_ubuntu_gnome(monkeypatch): + tray_module = importlib.import_module("core.background.tray_service") + monkeypatch.setattr(tray_module.sys, "platform", "linux") + monkeypatch.delenv("PYSTRAY_BACKEND", raising=False) + monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME") + + TrayService._configure_linux_backend() + + assert os.environ["PYSTRAY_BACKEND"] == "appindicator" + + +def test_tray_service_keeps_explicit_linux_backend_choice(monkeypatch): + tray_module = importlib.import_module("core.background.tray_service") + monkeypatch.setattr(tray_module.sys, "platform", "linux") + monkeypatch.setenv("PYSTRAY_BACKEND", "xorg") + monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") + + TrayService._configure_linux_backend() + + assert os.environ["PYSTRAY_BACKEND"] == "xorg" + + +def test_linux_indicator_icon_is_compact_and_opaque(monkeypatch): + from PIL import Image, ImageDraw + + tray_module = importlib.import_module("core.background.tray_service") + monkeypatch.setattr(tray_module.sys, "platform", "linux") + + tray = TrayService(object(), object(), object()) + tray._Image = Image + tray._ImageDraw = ImageDraw + image = tray._make_icon() + + assert image.size == (64, 64) + assert image.mode == "RGBA" + assert image.getpixel((0, 0))[3] == 255 + + +def test_linux_tray_service_runs_appindicator_loop_in_worker_thread(monkeypatch): + import threading + + tray_module = importlib.import_module("core.background.tray_service") + tray = TrayService(object(), object(), object()) + started = threading.Event() + + class _Icon: + def run(self): + started.set() + + def run_detached(self): # pragma: no cover - must not be chosen on Linux + raise AssertionError("Linux must own a worker loop") + + tray.icon = _Icon() + monkeypatch.setattr(tray_module.sys, "platform", "linux") + + tray._start_icon_loop() + + assert started.wait(1.0) + assert tray._thread is not None diff --git a/tests/test_platform_runtime.py b/tests/test_platform_runtime.py new file mode 100644 index 0000000..9ce0924 --- /dev/null +++ b/tests/test_platform_runtime.py @@ -0,0 +1,26 @@ +from core.platform import PlatformRuntime +from core.platform.capabilities import CapabilityState, DesktopCapabilities +from core.platform.fakes import FakeTrayBackend + + +def test_runtime_does_not_start_services_implicitly(): + runtime = PlatformRuntime.create("darwin") + + assert runtime.services.tray is None + assert not runtime.capabilities.tray.is_usable + + +def test_runtime_delegates_tray_lifecycle(): + caps = DesktopCapabilities(tray=CapabilityState.available("test")) + tray = FakeTrayBackend(capabilities=caps) + services = PlatformRuntime.create("darwin").services + services = services.__class__( + capabilities=services.capabilities, + tray=tray, + ) + runtime = PlatformRuntime(services) + + assert runtime.start_tray(["quit"]) + assert tray.running + runtime.stop() + assert not tray.running diff --git a/tests/test_quick_panel.py b/tests/test_quick_panel.py index 4d4535a..87fc33c 100644 --- a/tests/test_quick_panel.py +++ b/tests/test_quick_panel.py @@ -9,7 +9,7 @@ from core.background.tray_service import TrayService from core.quick_panel_controller import QuickPanelController from localization import LocalizationManager -from main import _update_runtime_views +from main import _update_runtime_view from ui.components.quick_color_studio_adapter import ColorStudioQuickAdapter from ui.quick_panel_view import QuickPanelView from ui.theme import Theme @@ -290,15 +290,13 @@ def update_state(self, state: dict) -> None: self.states.append(dict(state)) -def test_runtime_state_updates_full_and_quick_views() -> None: +def test_runtime_state_updates_main_view() -> None: full = _StateConsumer() - quick = _StateConsumer() state = {"state": True, "dimming": 72} - _update_runtime_views(full, quick, state) + _update_runtime_view(full, state) assert full.states == [state] - assert quick.states == [state] def test_controller_selects_one_device_and_refreshes_snapshot() -> None: @@ -360,10 +358,10 @@ def test_open_quick_reuses_window_and_replaces_only_host_content() -> None: assert host.content is quick_view assert controller.window_mode == "quick" - assert page.window.width == 440 - assert page.window.height == 680 - assert page.window.min_width == 440 - assert page.window.min_height == 680 + assert page.window.width == 380 + assert page.window.height == 600 + assert page.window.min_width == 380 + assert page.window.min_height == 600 assert page.window.visible is True assert page.window.skip_task_bar is True assert page.update_count == 1 @@ -386,12 +384,12 @@ def test_open_quick_uses_fixed_overlay_chrome_and_bottom_right_position() -> Non controller.open_quick() - assert page.window.width == 440 - assert page.window.height == 680 - assert page.window.min_width == 440 - assert page.window.min_height == 680 - assert page.window.left == 1464 - assert page.window.top == 384 + assert page.window.width == 380 + assert page.window.height == 600 + assert page.window.min_width == 380 + assert page.window.min_height == 600 + assert page.window.left == 1524 + assert page.window.top == 464 assert page.window.always_on_top is True assert page.window.frameless is True assert page.window.title_bar_hidden is True @@ -783,9 +781,9 @@ def test_color_studio_adapter_forwards_compact_viewport() -> None: i18n=LocalizationManager(preference="en"), ) - adapter.set_viewport(390, 620) + adapter.set_viewport(350, 540) - assert studio.viewports == [(390, 620)] + assert studio.viewports == [(350, 540)] def test_color_studio_adapter_forwards_external_light_state() -> None: @@ -918,7 +916,7 @@ def test_view_builds_premium_card_shell_without_desktop_navigation() -> None: isinstance(control, ft.NavigationRail) for control in view.shell.content.controls ) - assert adapter.viewports == [(390, 620)] + assert adapter.viewports == [(350, 540)] def test_view_header_renders_active_device_and_online_state() -> None: @@ -1096,6 +1094,7 @@ def test_windows_single_click_waits_then_opens_quick_panel(monkeypatch) -> None: _Timer.instances.clear() monkeypatch.setattr(tray_module.os, "name", "nt") + monkeypatch.setattr(tray_module.sys, "platform", "win32") monkeypatch.setattr(tray_module.threading, "Timer", _Timer) assert tray._handle_tray_primary_click() is False @@ -1120,6 +1119,7 @@ def test_windows_double_click_cancels_quick_panel_and_opens_full_app( ticks = iter((10.0, 10.2)) monkeypatch.setattr(tray_module.os, "name", "nt") + monkeypatch.setattr(tray_module.sys, "platform", "win32") monkeypatch.setattr(tray_module.threading, "Timer", _Timer) monkeypatch.setattr(tray_module.time, "monotonic", lambda: next(ticks)) @@ -1136,6 +1136,7 @@ def test_non_windows_primary_click_toggles_quick_panel(monkeypatch) -> None: tray = _quick_tray(calls) monkeypatch.setattr(tray_module.os, "name", "posix") + monkeypatch.setattr(tray_module.sys, "platform", "linux") assert tray._handle_tray_primary_click() is True assert calls == ["quick"] diff --git a/tests/test_settings_updates.py b/tests/test_settings_updates.py new file mode 100644 index 0000000..a491a0f --- /dev/null +++ b/tests/test_settings_updates.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from core.update_checker import ReleaseInfo +from localization import LocalizationManager +from ui.components.settings_panel import SettingsPanel + + +def _panel() -> SettingsPanel: + panel = SettingsPanel.__new__(SettingsPanel) + panel.i18n = LocalizationManager(preference="en") + panel._update_check_in_progress = True + panel._available_release = None + panel.btn_check_updates = SimpleNamespace(disabled=True) + panel.btn_open_release = SimpleNamespace(visible=False) + panel.update_status = SimpleNamespace(value="", color="") + return panel + + +def test_available_update_is_exposed_only_with_official_release_link(): + panel = _panel() + release = ReleaseInfo( + version="1.2.1", + notes_url="https://github.com/yvvvl/WizzController/releases/tag/v1.2.1", + ) + + panel._apply_update_result(release) + + assert panel.btn_check_updates.disabled is False + assert panel.btn_open_release.visible is True + assert panel._available_release == release + assert "1.2.1" in panel.update_status.value + + +def test_untrusted_release_link_is_not_exposed_to_the_user(): + panel = _panel() + release = ReleaseInfo(version="1.2.1", notes_url="https://example.test/download") + + panel._apply_update_result(release) + + assert panel.btn_open_release.visible is False + assert panel._available_release is None + + +def test_update_network_error_is_non_blocking_and_readable(): + panel = _panel() + + panel._apply_update_result(None, "unavailable") + + assert panel.btn_check_updates.disabled is False + assert panel.btn_open_release.visible is False + assert "unavailable" in panel.update_status.value.lower() + + +def test_updates_layout_uses_responsive_columns_not_expanded_wrapping_row(): + source = __import__("inspect").getsource(SettingsPanel._build) + + assert 'ft.ResponsiveRow(\n breakpoints=PANEL_BREAKPOINTS' in source + assert 'vertical_alignment=ft.CrossAxisAlignment.CENTER,\n wrap=True,' not in source diff --git a/tests/test_target_selection.py b/tests/test_target_selection.py new file mode 100644 index 0000000..e610bb7 --- /dev/null +++ b/tests/test_target_selection.py @@ -0,0 +1,63 @@ +from core.light_controller import LightController + + +def _controller_for_targets(mode, selected, reachable): + controller = LightController.__new__(LightController) + controller._target_mode = mode + controller._selected_ips = set(selected) + controller._reachable_targets = lambda: set(reachable) + controller._saved_targets = lambda: set(reachable) + controller._ensure_active_ip = lambda: "192.168.1.10" + controller._slider_interval_ms = 65 + return controller + + +def test_selected_mode_targets_only_requested_lights(): + controller = _controller_for_targets( + "selected", + {"192.168.1.10", "192.168.1.12"}, + {"192.168.1.10", "192.168.1.11", "192.168.1.12"}, + ) + + assert controller._control_targets() == { + "192.168.1.10", + "192.168.1.12", + } + + +def test_all_mode_keeps_all_reachable_lights(): + controller = _controller_for_targets( + "all", + {"192.168.1.10"}, + {"192.168.1.10", "192.168.1.11", "192.168.1.12"}, + ) + + assert controller._control_targets() == { + "192.168.1.10", + "192.168.1.11", + "192.168.1.12", + } + + +def test_single_mode_keeps_only_active_light(): + controller = _controller_for_targets( + "single", + {"192.168.1.10", "192.168.1.12"}, + {"192.168.1.10", "192.168.1.11", "192.168.1.12"}, + ) + + assert controller._control_targets() == {"192.168.1.10"} + + +def test_selected_mode_snapshot_preserves_selected_ips(): + controller = _controller_for_targets( + "selected", + {"192.168.1.10", "192.168.1.12"}, + {"192.168.1.10", "192.168.1.11", "192.168.1.12"}, + ) + + snapshot = controller.get_target_config() + + assert snapshot["mode"] == "selected" + assert snapshot["selected_ips"] == ["192.168.1.10", "192.168.1.12"] + assert snapshot["targets"] == ["192.168.1.10", "192.168.1.12"] diff --git a/tests/test_target_selector.py b/tests/test_target_selector.py new file mode 100644 index 0000000..e44e1d3 --- /dev/null +++ b/tests/test_target_selector.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from localization import LocalizationManager +from ui.components.target_selector import TargetSelector + + +class FakeWiz: + def __init__(self, *, mode: str = "single", selected_ips=None): + self.mode = mode + self.selected_ips = list(selected_ips or ["192.168.1.10"]) + self.active_ip = self.selected_ips[0] if self.selected_ips else "192.168.1.10" + self.calls: list[tuple[str, object]] = [] + self.bulbs = [ + {"ip": "192.168.1.10", "name": "Desk", "state": {"state": True}}, + {"ip": "192.168.1.11", "name": "Floor", "state": {"state": False}}, + {"ip": "192.168.1.12", "name": "Shelf", "state": {"state": True}}, + ] + + def get_bulbs_detailed(self): + return list(self.bulbs) + + def get_target_config(self): + return { + "mode": self.mode, + "active_ip": self.active_ip, + "selected_ips": list(self.selected_ips), + } + + def set_target_mode(self, mode: str): + self.mode = mode + self.calls.append(("mode", mode)) + + def set_active_bulb(self, ip: str): + self.active_ip = ip + self.selected_ips = [ip] + self.mode = "single" + self.calls.append(("single", ip)) + + def set_target_selection(self, ips): + self.selected_ips = list(ips) + self.mode = "selected" + self.calls.append(("selected", list(ips))) + + +def test_refresh_keeps_partial_selection_and_shows_count(): + selector = TargetSelector( + FakeWiz(mode="selected", selected_ips=["192.168.1.10", "192.168.1.12"]), + i18n=LocalizationManager(preference="en"), + ) + + assert selector.selected_targets == ["192.168.1.10", "192.168.1.12"] + assert selector.selection_status.value == "2 of 3 selected" + + +def test_toggle_from_partial_selection_uses_existing_controller_contract(): + wiz = FakeWiz(mode="selected", selected_ips=["192.168.1.10", "192.168.1.12"]) + selector = TargetSelector(wiz, i18n=LocalizationManager(preference="en")) + + selector.toggle_selection("192.168.1.11") + + assert wiz.calls[-1] == ( + "mode", + "all", + ) + assert selector.selection_status.value == "All 3 selected" + + +def test_empty_selector_explains_that_no_light_is_saved(): + wiz = FakeWiz() + wiz.bulbs = [] + selector = TargetSelector(wiz, i18n=LocalizationManager(preference="es")) + + assert selector.selected_targets == [] + assert selector.selection_status.value == "Sin ampolletas guardadas" + + +def test_selection_notifies_visual_consumer_without_waiting_for_light_action(): + calls: list[str] = [] + selector = TargetSelector( + FakeWiz(), + i18n=LocalizationManager(preference="en"), + on_selection_changed=lambda: calls.append("updated"), + ) + + selector.toggle_selection("192.168.1.11") + + assert calls diff --git a/tests/test_tray_window_restore.py b/tests/test_tray_window_restore.py index f978d95..11e139a 100644 --- a/tests/test_tray_window_restore.py +++ b/tests/test_tray_window_restore.py @@ -182,43 +182,34 @@ def update(self): loop.close() -def test_windows_tray_primary_action_requires_double_click(monkeypatch): +def test_windows_tray_primary_action_restores_main_window_immediately(monkeypatch): tray = TrayService(_Page(), object(), _Runtime()) - tray._double_click_seconds = 0.5 calls: list[bool] = [] - ticks = iter((10.0, 10.18)) monkeypatch.setattr(tray_module.os, "name", "nt") - monkeypatch.setattr(tray_module.time, "monotonic", lambda: next(ticks)) + monkeypatch.setattr(tray_module.sys, "platform", "win32") monkeypatch.setattr( tray, - "toggle_window", + "show_window", lambda: calls.append(True) or True, ) - assert tray._handle_tray_primary_click() is False - assert calls == [] assert tray._handle_tray_primary_click() is True assert calls == [True] -def test_windows_tray_primary_action_resets_after_timeout(monkeypatch): +def test_non_windows_tray_primary_action_restores_main_window_immediately(monkeypatch): tray = TrayService(_Page(), object(), _Runtime()) - tray._double_click_seconds = 0.4 calls: list[bool] = [] - ticks = iter((20.0, 20.8, 21.0)) - monkeypatch.setattr(tray_module.os, "name", "nt") - monkeypatch.setattr(tray_module.time, "monotonic", lambda: next(ticks)) + monkeypatch.setattr(tray_module.os, "name", "posix") + monkeypatch.setattr(tray_module.sys, "platform", "linux") monkeypatch.setattr( tray, - "toggle_window", + "show_window", lambda: calls.append(True) or True, ) - assert tray._handle_tray_primary_click() is False - assert tray._handle_tray_primary_click() is False - assert calls == [] assert tray._handle_tray_primary_click() is True assert calls == [True] diff --git a/tests/test_ui_state_dispatch.py b/tests/test_ui_state_dispatch.py index 9f289bb..6e4ed49 100644 --- a/tests/test_ui_state_dispatch.py +++ b/tests/test_ui_state_dispatch.py @@ -29,6 +29,15 @@ def update(self) -> None: self.updates += 1 +class TaskPage(FakePage): + def __init__(self) -> None: + super().__init__() + self.tasks = [] + + def run_task(self, handler) -> None: + self.tasks.append(handler) + + def test_wiz_callback_is_dispatched_through_page_loop() -> None: page = FakePage() received: list[dict] = [] @@ -70,3 +79,14 @@ def test_closed_page_loop_rejects_callback() -> None: assert scheduled is False assert received == [] assert page.updates == 0 + + +def test_real_page_task_bridge_is_preferred_when_available() -> None: + page = TaskPage() + received: list[dict] = [] + + scheduled = app_main._dispatch_wiz_state(page, received.append, {"state": True}) + + assert scheduled is True + assert len(page.tasks) == 1 + assert received == [] diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py new file mode 100644 index 0000000..6d4229b --- /dev/null +++ b/tests/test_update_checker.py @@ -0,0 +1,26 @@ +from core.update_checker import is_update_available, select_latest_release + + +PAYLOADS = [ + {"tag_name": "v1.2.0-beta.1", "prerelease": True}, + {"tag_name": "v1.1.1", "prerelease": False}, + {"tag_name": "v1.1.0", "prerelease": False}, +] + + +def test_stable_channel_ignores_prereleases(): + release = select_latest_release(PAYLOADS, channel="stable") + assert release is not None + assert release.version == "1.1.1" + + +def test_beta_channel_can_select_prerelease(): + release = select_latest_release(PAYLOADS, channel="beta") + assert release is not None + assert release.version == "1.2.0-beta.1" + + +def test_update_comparison_is_deterministic(): + release = select_latest_release(PAYLOADS) + assert is_update_available("1.1.0", release) + assert not is_update_available("1.1.1", release) diff --git a/tests/test_update_client.py b/tests/test_update_client.py new file mode 100644 index 0000000..f9582d9 --- /dev/null +++ b/tests/test_update_client.py @@ -0,0 +1,41 @@ +import json + +from core.update_client import ReleaseClient + + +class _Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps([ + {"tag_name": "v1.3.0-beta.1", "prerelease": True}, + {"tag_name": "v1.2.0", "prerelease": False}, + ]).encode("utf-8") + + +def test_release_client_selects_stable(monkeypatch): + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request.full_url, timeout, request.get_header("User-agent"))) + return _Response() + + monkeypatch.setattr("core.update_client.urlopen", fake_urlopen) + release = ReleaseClient(timeout=3).latest() + + assert release is not None + assert release.version == "1.2.0" + assert calls[0][0].endswith("/repos/yvvvl/WizzController/releases") + assert calls[0][1] == 3 + + +def test_release_client_can_select_beta(monkeypatch): + monkeypatch.setattr("core.update_client.urlopen", lambda *args, **kwargs: _Response()) + release = ReleaseClient().latest(channel="beta") + + assert release is not None + assert release.version == "1.3.0-beta.1" diff --git a/ui/app.py b/ui/app.py index 188376e..1d1de21 100644 --- a/ui/app.py +++ b/ui/app.py @@ -24,11 +24,18 @@ class WizzApp(ft.Container): vida de ventana/tray. """ - def __init__(self, page: ft.Page, wiz_controller, hotkeys_manager=None): + def __init__( + self, + page: ft.Page, + wiz_controller, + hotkeys_manager=None, + platform_services=None, + ): super().__init__() self.page_ref = page self.wiz = wiz_controller self.hotkeys_manager = hotkeys_manager + self.platform_services = platform_services self.runtime = AppRuntimeManager() self.i18n = get_manager() self.language_preference = RuntimeLanguagePreference(self.runtime) @@ -40,6 +47,9 @@ def __init__(self, page: ft.Page, wiz_controller, hotkeys_manager=None): self._viewport = Viewport(1080, 720) self._shell_mode = "" self._rail_width = 92.0 + self.hotkeys_available = bool( + self.hotkeys_manager is not None and self.hotkeys_manager.available + ) self.panels = [ HomePanel(self.wiz, i18n=self.i18n), @@ -52,6 +62,7 @@ def __init__(self, page: ft.Page, wiz_controller, hotkeys_manager=None): i18n=self.i18n, on_language_change=self.set_language_preference, runtime=self.runtime, + platform_services=self.platform_services, ), HotkeysPanel(self.wiz, manager=self.hotkeys_manager, i18n=self.i18n), ] @@ -102,8 +113,9 @@ def __init__(self, page: ft.Page, wiz_controller, hotkeys_manager=None): label=self._t("nav.routines"), ), ft.NavigationRailDestination(icon=ft.Icons.SETTINGS_OUTLINED, selected_icon=ft.Icons.SETTINGS_ROUNDED, label=self._t("nav.settings")), - ft.NavigationRailDestination(icon=ft.Icons.KEYBOARD_OUTLINED, selected_icon=ft.Icons.KEYBOARD_ROUNDED, label=self._t("nav.hotkeys")), + *([self._hotkeys_destination()] if self.hotkeys_available else []), ], + trailing=self._disabled_hotkeys_indicator() if not self.hotkeys_available else None, on_change=self._on_nav, ) @@ -129,6 +141,35 @@ def __init__(self, page: ft.Page, wiz_controller, hotkeys_manager=None): def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) + def _hotkeys_destination(self): + return ft.NavigationRailDestination( + icon=ft.Icons.KEYBOARD_OUTLINED, + selected_icon=ft.Icons.KEYBOARD_ROUNDED, + label=self._t("nav.hotkeys"), + ) + + def _disabled_hotkeys_indicator(self): + """A non-interactive, explanatory Linux beta capability indicator.""" + self.hotkeys_trailing_label = ft.Text( + self._t("nav.hotkeys"), color=Theme.MUTED, size=11, text_align=ft.TextAlign.CENTER + ) + self.hotkeys_trailing_status = ft.Text( + self._t("hotkeys.linux_disabled_short"), color=Theme.FAINT, size=9, text_align=ft.TextAlign.CENTER + ) + return ft.Container( + tooltip=self._t("hotkeys.linux_disabled_tooltip"), + padding=ft.Padding.only(top=10, bottom=14), + content=ft.Column( + [ + ft.Icon(ft.Icons.KEYBOARD_OUTLINED, color=Theme.MUTED, size=22), + self.hotkeys_trailing_label, + self.hotkeys_trailing_status, + ], + spacing=2, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + ) + def set_language_preference(self, preference: str) -> str: normalized = self.language_preference.save(preference) changed = self.i18n.set_preference(normalized) @@ -138,8 +179,12 @@ def set_language_preference(self, preference: str) -> str: def _on_language_changed(self, language: str) -> None: labels = translated_navigation(self.i18n) - for destination, label in zip(self.rail.destinations, labels): + visible_labels = labels if self.hotkeys_available else labels[:-1] + for destination, label in zip(self.rail.destinations, visible_labels): destination.label = label + if not self.hotkeys_available: + self.hotkeys_trailing_label.value = self._t("nav.hotkeys") + self.hotkeys_trailing_status.value = self._t("hotkeys.linux_disabled_short") try: self.page_ref.title = self._t("app.name") @@ -243,7 +288,12 @@ def _sync_panel(self, idx: int, state: dict): pass def _on_nav(self, e): - self.navigate_to(int(e.control.selected_index or 0)) + index = int(e.control.selected_index or 0) + if index == 6 and not self.hotkeys_available: + e.control.selected_index = self.selected_index + supdate(e.control) + return + self.navigate_to(index) def navigate_to(self, idx: int) -> None: """Cambia de panel conservando el viewport y el estado sincronizado. @@ -255,6 +305,8 @@ def navigate_to(self, idx: int) -> None: """ idx = max(0, min(len(self.panels) - 1, int(idx))) + if idx == 6 and not self.hotkeys_available: + return self.rail.selected_index = idx self.selected_index = idx self.content_area.content = self.panels[idx] diff --git a/ui/components/dev_virtual_bulbs.py b/ui/components/dev_virtual_bulbs.py new file mode 100644 index 0000000..f605908 --- /dev/null +++ b/ui/components/dev_virtual_bulbs.py @@ -0,0 +1,146 @@ +"""Visual development fixture for :mod:`core.dev_virtual_lights`.""" +from __future__ import annotations + +import math + +import flet as ft + +from ui.theme import Theme, supdate + + +def _kelvin_rgb(kelvin: int) -> str: + """Compact, presentation-only CCT approximation.""" + temperature = max(1000.0, min(40000.0, float(kelvin))) / 100.0 + if temperature <= 66: + red = 255 + green = 99.4708025861 * math.log(temperature) - 161.1195681661 + blue = 0 if temperature <= 19 else 138.5177312231 * math.log(temperature - 10) - 305.0447927307 + else: + red = 329.698727446 * ((temperature - 60) ** -0.1332047592) + green = 288.1221695283 * ((temperature - 60) ** -0.0755148492) + blue = 255 + values = [max(0, min(255, round(value))) for value in (red, green, blue)] + return "#{:02x}{:02x}{:02x}".format(*values) + + +def _state_color(state: dict) -> str: + if not state.get("state", True): + return "#334155" + if isinstance(state.get("_virtual_rgb"), (tuple, list)) and len(state["_virtual_rgb"]) == 3: + values = [max(0, min(255, int(value))) for value in state["_virtual_rgb"]] + return "#{:02x}{:02x}{:02x}".format(*values) + if all(key in state for key in ("r", "g", "b")): + values = [max(0, min(255, int(state[key]))) for key in ("r", "g", "b")] + return "#{:02x}{:02x}{:02x}".format(*values) + return _kelvin_rgb(int(state.get("temp", 4000))) + + +class DevVirtualBulbPreview(ft.Container): + """Live visual state of virtual lights, rendered only in DEV mode.""" + + def __init__(self, wiz, *, i18n, on_target_selected=None): + super().__init__( + padding=16, + border_radius=Theme.R_MD, + bgcolor=Theme.CARD, + border=ft.Border.all(1, Theme.ACCENT), + ) + self.wiz = wiz + self.i18n = i18n + self.on_target_selected = on_target_selected + self._cards: dict[str, ft.Container] = {} + self.rows = ft.ResponsiveRow(spacing=10, run_spacing=10) + self.content = ft.Column( + [ + ft.Row( + [ + ft.Row( + [ + ft.Icon(ft.Icons.SCIENCE_ROUNDED, color=Theme.ACCENT, size=18), + ft.Text(self._t("dev.virtual.title"), style=Theme.LABEL), + ], + spacing=8, + ), + ft.Text(self._t("dev.virtual.no_network"), color=Theme.WARNING, size=11), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + ft.Text(self._t("dev.virtual.hint"), color=Theme.MUTED, size=12), + self.rows, + ], + spacing=10, + ) + self.refresh() + + def _t(self, key: str, **values) -> str: + return self.i18n.translate(key, **values) + + def refresh(self) -> None: + items = self.wiz.get_virtual_bulbs() + self._cards = {str(item["ip"]): self._bulb_card(item) for item in items} + self.rows.controls = list(self._cards.values()) + supdate(self.rows) + + def refresh_selection(self) -> None: + """Update target borders without reading or redrawing scene colours.""" + targeted = {str(item["ip"]) for item in self.wiz.get_virtual_bulbs() if item.get("targeted")} + for ip, card in self._cards.items(): + active = ip in targeted + card.bgcolor = Theme.CARD_HI if active else Theme.SURFACE + card.border = ft.Border.all(1, Theme.PRIMARY if active else Theme.STROKE) + supdate(card) + + def _bulb_card(self, item: dict) -> ft.Container: + state = dict(item.get("state") or {}) + color = _state_color(state) + on = bool(state.get("state", True)) + brightness = int(state.get("dimming", 100) or 100) + targeted = bool(item.get("targeted")) + glow = ft.Colors.with_opacity(max(0.10, min(0.55, brightness / 180)), color) if on else None + return ft.Container( + col={"xs": 12, "sm": 6, "md": 4}, + padding=12, + border_radius=Theme.R_SM, + bgcolor=Theme.CARD_HI if targeted else Theme.SURFACE, + border=ft.Border.all(1, Theme.PRIMARY if targeted else Theme.STROKE), + on_click=lambda _e, ip=item["ip"]: self._select_bulb(ip), + ink=True, + content=ft.Row( + [ + ft.Container( + width=38, + height=38, + border_radius=19, + bgcolor=color, + shadow=ft.BoxShadow(blur_radius=18, color=glow) if glow else None, + content=ft.Icon(ft.Icons.LIGHTBULB_ROUNDED, color="#ffffff" if on else Theme.MUTED, size=21), + alignment=ft.Alignment.CENTER, + ), + ft.Column( + [ + ft.Text(str(item["name"]), color=Theme.TEXT, weight=ft.FontWeight.W_600, size=12), + ft.Text( + self._t("dev.virtual.state", power=self._t("dev.virtual.on") if on else self._t("dev.virtual.off"), brightness=brightness), + color=Theme.MUTED, + size=11, + ), + ], + spacing=2, + expand=True, + ), + ], + spacing=10, + ), + ) + + def _select_bulb(self, ip: str) -> None: + self.wiz.set_active_bulb(ip) + # Repaint synchronously: this is a direct selection interaction, not + # a light command that can wait for a controller callback. + self.refresh_selection() + callback = self.on_target_selected + if callable(callback): + try: + callback() + except Exception: + pass diff --git a/ui/components/home_panel.py b/ui/components/home_panel.py index f9dab1b..80c2e3a 100644 --- a/ui/components/home_panel.py +++ b/ui/components/home_panel.py @@ -9,6 +9,7 @@ from ui.theme import Theme, mounted, supdate from ui.interaction import LocalEditGuard from ui.components.target_selector import TargetSelector +from ui.components.dev_virtual_bulbs import DevVirtualBulbPreview EO = ft.AnimationCurve.EASE_OUT @@ -216,7 +217,21 @@ def _build(self): ) ) - self.controls = [self.header, self.target_selector, self.master_card, bri_card, ft.Text(self._t("home.quick_section"), style=Theme.LABEL), quick, favs_card] + self.dev_virtual_preview = ( + DevVirtualBulbPreview( + self.wiz, + i18n=self.i18n, + on_target_selected=self.target_selector.refresh, + ) + if getattr(self.wiz, "is_virtual", False) + else None + ) + if self.dev_virtual_preview is not None: + self.target_selector.on_selection_changed = self.dev_virtual_preview.refresh_selection + self.controls = [self.header, self.target_selector] + if self.dev_virtual_preview is not None: + self.controls.append(self.dev_virtual_preview) + self.controls.extend([self.master_card, bri_card, ft.Text(self._t("home.quick_section"), style=Theme.LABEL), quick, favs_card]) self._render_favorites() # ------------------------------------------------------------------ # @@ -396,7 +411,13 @@ def sync_state(self, state: dict): self._last_state = dict(state or {}) if not mounted(self): return + selection_only = bool(state.get("_virtual_selection_only")) self.target_selector.refresh() + if self.dev_virtual_preview is not None: + if selection_only: + self.dev_virtual_preview.refresh_selection() + else: + self.dev_virtual_preview.refresh() if "dimming" in state and not self._bri_guard.blocks(state["dimming"], tolerance=1): self.bri_slider.value = state["dimming"] self.bri_value.value = self._t("common.percent_value", value=int(state["dimming"])) @@ -407,7 +428,13 @@ def sync_state(self, state: dict): count = int(s.get("count", 0) or 0) active = int(s.get("active", 0) or 0) extra = f" · {s['label']}" if s.get("label") else "" - mode = self._t("routines.target.single") if s.get("target_mode") == "single" else self._t("routines.target.all") + target_mode = s.get("target_mode", "single") + if target_mode == "selected": + mode = self._t("target.mode.selected") + elif target_mode == "all": + mode = self._t("routines.target.all") + else: + mode = self._t("routines.target.single") active_ip = s.get("active_ip") or "—" if active > 0: diff --git a/ui/components/hotkeys_panel.py b/ui/components/hotkeys_panel.py index 003213b..4ba14bc 100644 --- a/ui/components/hotkeys_panel.py +++ b/ui/components/hotkeys_panel.py @@ -78,6 +78,9 @@ def set_language(self, language: str | None = None) -> None: def _build(self): self._cards = [] + if not self.manager.available: + self._build_unavailable() + return self.status_dot = ft.Container(width=9, height=9, border_radius=5, bgcolor=Theme.MUTED) self.status_text = ft.Text("", color=Theme.MUTED, size=12, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS) self.warning_text = ft.Text("", color=Theme.WARNING, size=12, max_lines=3, overflow=ft.TextOverflow.ELLIPSIS) @@ -300,6 +303,42 @@ def _build(self): self.controls = [self.header, status_card, creator, defaults, assigned] self._render() + def _build_unavailable(self) -> None: + """Show an honest, non-interactive capability state on Linux.""" + reason = self.manager.dependency_message() + self.controls = [ + ft.Container( + expand=True, + alignment=ft.Alignment.CENTER, + content=ft.Container( + width=560, + padding=28, + border_radius=Theme.R_MD, + bgcolor=Theme.CARD, + border=ft.Border.all(1, Theme.STROKE), + shadow=Theme.SHADOW, + content=ft.Column( + [ + ft.Container( + width=58, + height=58, + border_radius=18, + bgcolor=ft.Colors.with_opacity(0.14, Theme.MUTED), + alignment=ft.Alignment.CENTER, + content=ft.Icon(ft.Icons.KEYBOARD_OUTLINED, color=Theme.MUTED, size=30), + ), + ft.Text(self._t("hotkeys.linux_disabled_title"), style=Theme.H1), + ft.Text(self._t("hotkeys.linux_disabled_body"), color=Theme.MUTED, size=13), + ft.Container(height=4), + ft.Text(reason, color=Theme.FAINT, size=12), + ], + spacing=12, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + ), + ) + ] + # ------------------------------------------------------------------ # def _card(self, content): card = ft.Container( diff --git a/ui/components/settings_panel.py b/ui/components/settings_panel.py index 71ae38f..b558a97 100644 --- a/ui/components/settings_panel.py +++ b/ui/components/settings_panel.py @@ -1,6 +1,10 @@ from __future__ import annotations import os +import sys +import subprocess +import threading +from urllib.parse import urlparse import flet as ft from localization import ( @@ -11,9 +15,11 @@ normalize_language, translated_language_name, ) -from app_meta import APP_PRODUCT, display_version +from app_meta import APP_PRODUCT, APP_VERSION, display_version from config.app_runtime_manager import AppRuntimeManager from config.paths import config_dir, logs_dir +from core.update_checker import ReleaseInfo, is_update_available +from core.update_client import ReleaseClient from ui.responsive import PANEL_BREAKPOINTS, Viewport, dialog_dimensions from ui.theme import Theme, mounted, supdate from ui.components.target_selector import TargetSelector @@ -24,13 +30,26 @@ class SettingsPanel(ft.Column): # estado luminoso permanezca igual. WizzApp usa esta marca para no # descartar el callback final de una búsqueda. refresh_on_equal_state = True - def __init__(self, wiz, *, i18n=None, on_language_change=None, runtime=None): + def __init__( + self, + wiz, + *, + i18n=None, + on_language_change=None, + runtime=None, + release_client=None, + platform_services=None, + ): super().__init__(scroll=ft.ScrollMode.AUTO, spacing=18, expand=True) self.wiz = wiz self.i18n = i18n or get_manager() self._on_language_change = on_language_change self.runtime = runtime or AppRuntimeManager(i18n=self.i18n) self.runtime.i18n = self.i18n + self.release_client = release_client or ReleaseClient() + self.platform_services = platform_services + self._update_check_in_progress = False + self._available_release: ReleaseInfo | None = None self.language_preference = RuntimeLanguagePreference(self.runtime) if i18n is None: self.i18n.set_preference(self.language_preference.load()) @@ -249,7 +268,11 @@ def _build(self): self._runtime_option(self._t("runtime.tray_enabled"), self._t("runtime.tray_enabled.description"), self.tray_enabled_switch), self._runtime_option(self._t("runtime.close_to_tray"), self._t("runtime.close_to_tray.description"), self.minimize_to_tray_switch), self._runtime_option(self._t("runtime.open_minimized"), self._t("runtime.open_minimized.description"), self.open_minimized_switch), - self._runtime_option(self._t("runtime.start_with_windows"), self._t("runtime.start_with_windows.description"), self.startup_switch), + self._runtime_option( + self._t("runtime.start_at_login") if sys.platform.startswith("linux") else self._t("runtime.start_with_windows"), + self._t("runtime.start_at_login.description") if sys.platform.startswith("linux") else self._t("runtime.start_with_windows.description"), + self.startup_switch, + ), ], ) runtime_card = self._card( @@ -334,6 +357,39 @@ def _build(self): color=Theme.FAINT, size=11, ), + ft.Divider(color=Theme.STROKE, height=1), + # Do not put an expanded Column inside a wrapping Row: + # Flutter renders that combination as an ErrorWidget on + # desktop. ResponsiveRow keeps the compact layout while + # giving each breakpoint bounded dimensions. + ft.ResponsiveRow( + breakpoints=PANEL_BREAKPOINTS, + spacing=12, + run_spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + ft.Container( + col={"xs": 12, "md": 7}, + content=ft.Column( + [ + ft.Text(self._t("updates.title"), style=Theme.LABEL), + self._build_update_status(), + ], + spacing=3, + ), + ), + ft.Container( + col={"xs": 12, "md": 5}, + alignment=ft.Alignment.CENTER_RIGHT, + content=ft.Row( + [self.btn_check_updates, self.btn_open_release], + spacing=8, + wrap=True, + alignment=ft.MainAxisAlignment.END, + ), + ), + ], + ), ], spacing=10, ) @@ -362,6 +418,117 @@ def _card(self, content): shadow=Theme.SHADOW, ) + def _build_update_status(self) -> ft.Text: + self.update_status = ft.Text( + self._t("updates.idle", version=display_version()), + color=Theme.FAINT, + size=11, + ) + self.btn_check_updates = ft.OutlinedButton( + self._t("updates.check"), + icon=ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, + style=ft.ButtonStyle(color=Theme.TEXT, side=ft.BorderSide(1, Theme.STROKE)), + on_click=self._check_for_updates, + ) + self.btn_open_release = ft.TextButton( + self._t("updates.open_release"), + icon=ft.Icons.OPEN_IN_NEW_ROUNDED, + visible=False, + on_click=self._open_available_release, + ) + return self.update_status + + def _check_for_updates(self, e=None) -> None: + if self._update_check_in_progress: + return + self._update_check_in_progress = True + self._available_release = None + self.btn_check_updates.disabled = True + self.btn_open_release.visible = False + self.update_status.value = self._t("updates.checking") + self.update_status.color = Theme.MUTED + supdate(self.btn_check_updates) + supdate(self.btn_open_release) + supdate(self.update_status) + threading.Thread(target=self._check_for_updates_worker, daemon=True, name="wizz-release-check").start() + + def _check_for_updates_worker(self) -> None: + try: + release = self.release_client.latest(channel="stable") + self._dispatch_update_result(release, None) + except Exception: + # Connectivity, rate limiting and malformed data must never affect + # app startup or light control, and need no technical error surface. + self._dispatch_update_result(None, "unavailable") + + def _dispatch_update_result(self, release: ReleaseInfo | None, error: str | None) -> None: + def apply() -> None: + self._apply_update_result(release, error) + + page = getattr(self, "page", None) + run_task = getattr(page, "run_task", None) + if callable(run_task): + async def apply_on_page() -> None: + apply() + + try: + run_task(apply_on_page) + return + except Exception: + pass + apply() + + @staticmethod + def _is_official_release_url(value: str | None) -> bool: + parsed = urlparse(str(value or "")) + return ( + parsed.scheme == "https" + and parsed.netloc.lower() == "github.com" + and parsed.path.startswith("/yvvvl/WizzController/releases") + ) + + def _apply_update_result(self, release: ReleaseInfo | None, error: str | None = None) -> None: + self._update_check_in_progress = False + self.btn_check_updates.disabled = False + self._available_release = None + self.btn_open_release.visible = False + + if error: + self.update_status.value = self._t("updates.unavailable") + self.update_status.color = Theme.WARNING + elif release is None: + self.update_status.value = self._t("updates.no_release") + self.update_status.color = Theme.FAINT + elif is_update_available(APP_VERSION, release): + self.update_status.value = self._t("updates.available", version=release.version) + self.update_status.color = Theme.SUCCESS + if self._is_official_release_url(release.notes_url): + self._available_release = release + self.btn_open_release.visible = True + else: + self.update_status.value = self._t("updates.current", version=display_version()) + self.update_status.color = Theme.FAINT + + supdate(self.btn_check_updates) + supdate(self.btn_open_release) + supdate(self.update_status) + + def _open_available_release(self, e=None) -> None: + release = self._available_release + if release is None or not self._is_official_release_url(release.notes_url): + return + try: + page = getattr(self, "page", None) + launch_url = getattr(page, "launch_url", None) + if callable(launch_url): + launch_url(release.notes_url) + return + except Exception: + pass + self.update_status.value = self._t("updates.open_failed") + self.update_status.color = Theme.WARNING + supdate(self.update_status) + def _runtime_option(self, title: str, subtitle: str, switch: ft.Switch) -> ft.Container: return ft.Container( col={"xs": 12, "sm": 6, "lg": 3}, @@ -392,7 +559,13 @@ def _open_folder(self, path) -> None: if os.name == "nt": os.startfile(folder) # type: ignore[attr-defined] else: + system = getattr(self.platform_services, "system", None) + opener = getattr(system, "open_folder", None) + opened = bool(opener(folder)) if callable(opener) else False + if not opened: + subprocess.Popen(["xdg-open", folder]) self.runtime_status.value = folder + self.runtime_status.color = Theme.FAINT supdate(self.runtime_status) except Exception as exc: self.runtime_status.value = self._t("settings.open_folder_error", error=exc) diff --git a/ui/components/target_selector.py b/ui/components/target_selector.py index b845422..54c46cf 100644 --- a/ui/components/target_selector.py +++ b/ui/components/target_selector.py @@ -8,9 +8,10 @@ class TargetSelector(ft.Container): """Selector visual de bombillas con chips interactivos para selección individual y múltiple.""" - def __init__(self, wiz, *, i18n=None, compact: bool = False): + def __init__(self, wiz, *, i18n=None, compact: bool = False, on_selection_changed=None): self.wiz = wiz self.i18n = i18n or LocalizationManager(preference="es") + self.on_selection_changed = on_selection_changed self._viewport = Viewport(900, 720) self.selected_targets: list[str] = [] @@ -28,11 +29,11 @@ def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) def _build(self): + self.selection_status = ft.Text("", size=11, color=Theme.ACCENT, weight=ft.FontWeight.W_500) self.chip_container = ft.Row( spacing=8, wrap=True, run_spacing=8, - expand=True, alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) @@ -66,9 +67,16 @@ def _build(self): [ ft.Row( [ - ft.Icon(ft.Icons.LIGHTBULB_ROUNDED, color=Theme.ACCENT, size=17), - ft.Text(self._t("settings.target.section"), style=Theme.LABEL), + ft.Row( + [ + ft.Icon(ft.Icons.LIGHTBULB_ROUNDED, color=Theme.ACCENT, size=17), + ft.Text(self._t("settings.target.section"), style=Theme.LABEL), + ], + spacing=8, + ), + self.selection_status, ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, spacing=8, ), ft.Row( @@ -94,15 +102,14 @@ def _create_chip(self, bulb: dict) -> ft.Chip: return ft.Chip( label=ft.Text(name, size=12, color=ft.Colors.WHITE if is_selected else Theme.TEXT), on_click=lambda e, target_ip=ip: self.toggle_selection(target_ip), - bgcolor=Theme.ACCENT if is_selected else ft.Colors.with_opacity(0.15, Theme.SURFACE_VARIANT), - checkable=False, + bgcolor=Theme.ACCENT if is_selected else Theme.SURFACE, elevation=2 if is_selected else 0, shape=ft.RoundedRectangleBorder(radius=8), padding=ft.Padding(8, 4, 8, 4), leading=ft.Icon( ft.Icons.LIGHTBULB if is_on else ft.Icons.LIGHTBULB_OUTLINE, size=14, - color=ft.Colors.AMBER_300 if is_on else (ft.Colors.WHITE70 if is_selected else Theme.MUTED), + color=ft.Colors.AMBER_300 if is_on else (ft.Colors.WHITE_70 if is_selected else Theme.MUTED), ), ) @@ -148,26 +155,54 @@ def _apply_targets(self): elif len(self.selected_targets) == 1: if hasattr(self.wiz, "set_active_bulb"): self.wiz.set_active_bulb(self.selected_targets[0]) - if hasattr(self.wiz, "set_target_mode"): - self.wiz.set_target_mode("single") elif self.selected_targets: - if hasattr(self.wiz, "set_active_bulb"): + if len(self.selected_targets) > 1 and hasattr(self.wiz, "set_target_selection"): + self.wiz.set_target_selection(self.selected_targets) + elif hasattr(self.wiz, "set_active_bulb"): self.wiz.set_active_bulb(self.selected_targets[0]) def _update_chips(self): bulbs = self.wiz.get_bulbs_detailed() if hasattr(self.wiz, "get_bulbs_detailed") else [] self.chip_container.controls = [self._create_chip(b) for b in bulbs] + self.selection_status.value = self._selection_label(len(bulbs)) if mounted(self): - supdate(self.chip_container) + supdate(self) + + # A destination click is UI state in its own right. Consumers such as + # the DEV virtual-bulb preview must not wait for a later light command + # or the asynchronous controller callback to repaint their selection. + callback = self.on_selection_changed + if callable(callback): + try: + callback() + except Exception: + pass + + def _selection_label(self, total_bulbs: int) -> str: + selected = len(self.selected_targets) + if total_bulbs <= 0: + return self._t("target.selection.none") + if selected <= 1: + return self._t("target.selection.single") + if selected >= total_bulbs: + return self._t("target.selection.all", total=total_bulbs) + return self._t("target.selection.partial", selected=selected, total=total_bulbs) def refresh(self) -> None: cfg = self.wiz.get_target_config() if hasattr(self.wiz, "get_target_config") else {} bulbs = self.wiz.get_bulbs_detailed() if hasattr(self.wiz, "get_bulbs_detailed") else [] mode = cfg.get("mode", "single") active_ip = cfg.get("active_ip") + selected_ips = cfg.get("selected_ips", []) + available_ips = {str(b["ip"]) for b in bulbs if b.get("ip")} - if mode == "all": - self.selected_targets = [str(b["ip"]) for b in bulbs if b.get("ip")] + if not available_ips: + self.selected_targets = [] + elif mode == "all": + self.selected_targets = sorted(available_ips) + elif mode == "selected" and isinstance(selected_ips, (list, tuple, set)): + selected = [str(ip) for ip in selected_ips if str(ip) in available_ips] + self.selected_targets = selected or ([str(active_ip)] if active_ip in available_ips else []) elif active_ip: self.selected_targets = [str(active_ip)] elif bulbs: @@ -178,8 +213,7 @@ def refresh(self) -> None: self._update_chips() def set_language(self, language: str | None = None) -> None: - self.btn_select_all.tooltip = self._t("bulbs.select_all") - self.btn_invert.tooltip = self._t("bulbs.invert_selection") + self._build() self.refresh() def set_viewport(self, width: float, height: float, *, update: bool = True) -> None: diff --git a/ui/quick_panel_view.py b/ui/quick_panel_view.py index fe1be80..777a42c 100644 --- a/ui/quick_panel_view.py +++ b/ui/quick_panel_view.py @@ -35,7 +35,7 @@ def __init__( set_viewport = getattr(self.color_adapter, "set_viewport", None) if callable(set_viewport): - set_viewport(390, 620) + set_viewport(350, 540) self.title = ft.Text( self._t("quick.title"), @@ -150,14 +150,14 @@ def __init__( icon=ft.Icons.POWER_SETTINGS_NEW_ROUNDED, bgcolor=Theme.PRIMARY, color="white", - height=48, + height=42, on_click=lambda event: self.controller.turn_on(), expand=True, ) self.power_off = ft.OutlinedButton( self._t("home.off"), icon=ft.Icons.POWER_OFF_ROUNDED, - height=48, + height=42, on_click=lambda event: self.controller.turn_off(), expand=True, )