From b632db57d6a296a5ddf9f1d6fbed3604e9ad23a8 Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 06:24:30 -0400 Subject: [PATCH 01/39] feat: add opt-in platform runtime facade --- core/platform/__init__.py | 2 + core/platform/runtime.py | 37 +++++++++++++++++++ ...-27-01-linux-desktop-integration-phase1.md | 3 ++ tests/test_platform_runtime.py | 26 +++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 core/platform/runtime.py create mode 100644 tests/test_platform_runtime.py 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/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/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..45a2592 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,13 @@ 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. ## Deliberate limits - The composition helper is not wired into `main.py` yet. +- `PlatformRuntime` is not wired into `main.py` yet. - 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/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 From 6c319560c8fad8fd60c9d4cd9cd2f44a33f8d28c Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 06:27:05 -0400 Subject: [PATCH 02/39] feat: expose platform capabilities at app bootstrap --- .../2026-08-27-01-linux-desktop-integration-phase1.md | 6 ++++-- main.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) 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 45a2592..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 @@ -17,11 +17,13 @@ the Windows runtime, UI and WiZ core unchanged. - 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. -- `PlatformRuntime` 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/main.py b/main.py index 3cc7762..c71648d 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ 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 @@ -101,6 +102,10 @@ 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()) From 157faf89225f1f8ff9ecc0f4af7027be23b2586b Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 07:25:30 -0400 Subject: [PATCH 03/39] fix: stabilize quick panel layout and tray activation --- core/background/tray_service.py | 11 +++++++++-- core/quick_panel_controller.py | 8 ++++---- tests/test_quick_panel.py | 26 +++++++++++++------------- ui/components/target_selector.py | 2 -- ui/quick_panel_view.py | 6 +++--- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/core/background/tray_service.py b/core/background/tray_service.py index 821fc0a..ddc441b 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -255,10 +255,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: @@ -671,6 +670,14 @@ def show_window(self) -> bool: pystray. Después se sincroniza el modelo de Flet de forma oportunista. """ + # The Quick Panel reuses the main native window. A tray activation + # must leave compact mode before restoring/focusing it. + quick_controller = getattr(self.page, "_wizz_quick_panel", None) + if getattr(quick_controller, "window_mode", "full") in {"quick", "hidden"}: + open_full = getattr(quick_controller, "open_full", None) + if callable(open_full): + return bool(open_full()) + now = time.monotonic() if now - self._last_show_request < 0.12: return self._last_show_ok 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/tests/test_quick_panel.py b/tests/test_quick_panel.py index 4d4535a..153d4f6 100644 --- a/tests/test_quick_panel.py +++ b/tests/test_quick_panel.py @@ -360,10 +360,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 +386,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 +783,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 +918,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: diff --git a/ui/components/target_selector.py b/ui/components/target_selector.py index b845422..2928f3d 100644 --- a/ui/components/target_selector.py +++ b/ui/components/target_selector.py @@ -32,7 +32,6 @@ def _build(self): spacing=8, wrap=True, run_spacing=8, - expand=True, alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) @@ -95,7 +94,6 @@ def _create_chip(self, bulb: dict) -> 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, elevation=2 if is_selected else 0, shape=ft.RoundedRectangleBorder(radius=8), padding=ft.Padding(8, 4, 8, 4), 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, ) From 93c0136e257288bbd46121d2d61329fd07d54bac Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 15:13:45 -0400 Subject: [PATCH 04/39] feat: support transient multi-target selection --- core/light_controller.py | 39 ++++++++++++++- .../2026-08-27-03-quick-target-selection.md | 26 ++++++++++ tests/test_target_selection.py | 48 +++++++++++++++++++ ui/components/target_selector.py | 4 +- 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 docs/codex/queries/2026-08-27-03-quick-target-selection.md create mode 100644 tests/test_target_selection.py diff --git a/core/light_controller.py b/core/light_controller.py index 184472c..f173d1c 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() 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..865877a --- /dev/null +++ b/docs/codex/queries/2026-08-27-03-quick-target-selection.md @@ -0,0 +1,26 @@ +# Quick target selection + +Date: 2026-08-27 +Status: In Progress + +## 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. + +## Release scope + +This behavior belongs in the public Windows release. RGBIC remains isolated to +the closed beta channel. diff --git a/tests/test_target_selection.py b/tests/test_target_selection.py new file mode 100644 index 0000000..886da07 --- /dev/null +++ b/tests/test_target_selection.py @@ -0,0 +1,48 @@ +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" + 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"} diff --git a/ui/components/target_selector.py b/ui/components/target_selector.py index 2928f3d..fd6fff7 100644 --- a/ui/components/target_selector.py +++ b/ui/components/target_selector.py @@ -149,7 +149,9 @@ def _apply_targets(self): 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): From 51fde27a9cd973b83106073a9d7124e956f8e036 Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 15:40:55 -0400 Subject: [PATCH 05/39] fix: persist packaged Windows data in local appdata --- config/paths.py | 16 +++++++++++++++- tests/test_app_paths.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/config/paths.py b/config/paths.py index 471996b..42a7000 100644 --- a/config/paths.py +++ b/config/paths.py @@ -59,6 +59,15 @@ def config_dir() -> Path: target = Path(xdg_config) / APP_ARTIFACT / "config" return _prepare(target, migrate=True) + # Windows packaged builds must keep user data outside the install + # directory so upgrades do not overwrite configuration. + 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) + # 3. Modo Desarrollo Local (Guarda en el propio repo config/json) target = project_root() / "config" / "json" return _prepare(target, migrate=False) @@ -73,6 +82,11 @@ def logs_dir() -> Path: 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" + elif 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" else: target = project_root() / "logs" @@ -162,4 +176,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/tests/test_app_paths.py b/tests/test_app_paths.py index 77e23a9..1788bfa 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -45,6 +45,25 @@ def test_flet_storage_migrates_legacy_json_once(monkeypatch, tmp_path): assert json.loads((target / "hotkeys.json").read_text(encoding="utf-8")) == {"enabled": False} +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_json_manager_uses_override_and_atomic_save(monkeypatch, tmp_path): monkeypatch.setenv("WIZZ_CONFIG_DIR", str(tmp_path)) monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) From 2b3bd9b1afa49f4e202c5438f048f6118a9921ac Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 15:40:58 -0400 Subject: [PATCH 06/39] feat: add read-only GitHub release channel checks --- core/update_checker.py | 63 ++++++++++++++++++++++++++++++++++++ core/update_client.py | 37 +++++++++++++++++++++ tests/test_update_checker.py | 26 +++++++++++++++ tests/test_update_client.py | 41 +++++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 core/update_checker.py create mode 100644 core/update_client.py create mode 100644 tests/test_update_checker.py create mode 100644 tests/test_update_client.py 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/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" From 360db1173903b5c7916139c2e25c14f4ac12fa1d Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 15:41:04 -0400 Subject: [PATCH 07/39] docs: define temporary quick panel window behavior --- ...26-08-27-02-quick-panel-window-redesign.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md 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. From f158d3088f9ca9fdba481ab6967db29b5d8096db Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 17:39:58 -0400 Subject: [PATCH 08/39] fix: preserve configuration in packaged Flet builds --- config/paths.py | 17 +++++++++++ tests/test_app_paths.py | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/config/paths.py b/config/paths.py index 42a7000..ffbc735 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 ) diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index 1788bfa..c3ff232 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -64,6 +64,72 @@ def test_windows_frozen_build_uses_local_appdata(monkeypatch, tmp_path): assert (target / "config.json").exists() +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): monkeypatch.setenv("WIZZ_CONFIG_DIR", str(tmp_path)) monkeypatch.delenv("FLET_APP_STORAGE_DATA", raising=False) From e2452fee76bedcc8779dc7f2fc2ef3b71654a3b6 Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 18:06:34 -0400 Subject: [PATCH 09/39] docs: define v1.2 release and RGBIC beta roadmap --- docs/codex/PROJECT_CONTEXT.md | 103 +++-- ...2-public-release-and-rgbic-beta-roadmap.md | 375 ++++++++++++++++++ .../2026-08-27-03-quick-target-selection.md | 12 +- 3 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 6602ade..53fd68c 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -1,20 +1,26 @@ # WizZ Desktop — contexto maestro del proyecto -Última actualización: 2026-08-25 +Última actualización: 2026-08-27 ## 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 rama activa es `release/v1.2.0-windows`. El baseline documentado para la +planificación de release es `f158d30`, con el working tree limpio después de +validar el build empaquetado de Windows. La versión pública estable continúa +siendo `v1.1.0`; `v1.2.0` todavía es un release candidate y no debe anunciarse +como publicada hasta completar sus gates. -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 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. El runtime Flet de +Windows guarda datos por usuario mediante `FLET_APP_STORAGE_DATA`; no se debe +suponer una ruta absoluta fija ni incluir JSON reales en el artefacto. + +Linux conserva estado beta/foundation: detección de capacidades, autostart +XDG, apertura de carpetas y tray AppIndicator fueron probados de forma +aislada, pero el wiring productivo y el packaging siguen pendientes. 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 +63,17 @@ 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:** release pública Windows `v1.2.0`. +- **Estado de Quick Panel:** foundation y UI compacta implementadas; el + comportamiento actual se estabiliza para la release, mientras el rediseño + como ventana temporal independiente permanece aprobado pero diferido. +- **Targeting:** selección transitoria de una, varias o todas las luces + implementada y cubierta por tests; no requiere grupos persistentes. +- **Actualizaciones:** selección de canal y cliente GitHub read-only + implementados; todavía no existe descarga, instalación, reinicio ni + rollback automático. +- **Persistencia empaquetada:** validada mediante el storage por usuario que + Flet expone en runtime; los datos sobreviven al reemplazo del build. - **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 +86,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. +- **Cross-platform Foundation:** contratos, fakes, detección Linux y servicios + aislados de autostart, carpetas, ventana y tray existen. No hay wiring + productivo Linux, hotkeys Wayland resueltas ni package beta. macOS está + explícitamente diferido. ### Ramas relevantes @@ -89,6 +100,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 +165,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 +251,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 +293,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 +730,26 @@ 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; +- completar suite, build, smoke manual y artefacto Windows de `v1.2.0`; +- 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 wiring, lifecycle y packaging Linux antes del estado 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 +799,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,8 +842,8 @@ 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 aislados; el + runtime principal todavía no los conecta como soporte de producto; - `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; 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..f136ba4 --- /dev/null +++ b/docs/codex/plans/2026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md @@ -0,0 +1,375 @@ +# 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 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. +- Transient multi-target selection already implemented in commit `93c0136`. +- Packaged Flet persistence already validated through + `FLET_APP_STORAGE_DATA`. +- 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. +- Linux product wiring or package publication. +- 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 design in +`docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md`. It is not a +v1.2.0 release gate because Flet 0.85.2 does not currently provide a validated +second-window path for this application. The current functional panel is +stabilized; the architectural replacement continues on an isolated future +branch. + +## 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 continuation RGBIC closed beta branch + from the v1.2.0 stable base +``` + +The RGBIC beta must not become a prerequisite for the public release. Linux +validation must not delay the Windows stable artifact. + +## 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 + +### 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 + +### 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 + +### 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; +- current Quick Panel open/close and full-window restore; +- 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 the Flet-managed user data directory. +- 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. + +## 9. Phase E — v1.2.0 release candidate + +### 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. + +### 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 the release artifact 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 continuation + +Linux remains a separate post-v1.2 workstream: + +- preserve the validated platform contracts and fakes; +- finish the main-thread AppIndicator run-loop integration; +- wire services only after lifecycle tests and VM smoke pass; +- degrade hotkeys honestly on Wayland when permissions/backend are absent; +- design Unix single-instance activation before claiming product parity; +- 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 B with a focused audit of `TargetSelector` and Home: + +- freeze the current one/selected/all contract in component tests; +- define concise Spanish and English labels for partial selection; +- improve empty, single, partial and all visual states; +- preserve the working controller and protocol path; +- validate the result locally before making any updater UI or release metadata + change. 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 index 865877a..5a46770 100644 --- a/docs/codex/queries/2026-08-27-03-quick-target-selection.md +++ b/docs/codex/queries/2026-08-27-03-quick-target-selection.md @@ -1,7 +1,7 @@ # Quick target selection Date: 2026-08-27 -Status: In Progress +Status: Completed ## Decision @@ -20,6 +20,16 @@ compatible. 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 From fd26e637fbe1479f458f6b51cb9a2406270315e0 Mon Sep 17 00:00:00 2001 From: ign00 Date: Thu, 27 Aug 2026 23:49:12 -0400 Subject: [PATCH 10/39] feat: add virtual multi-light development fixture --- core/dev_virtual_lights.py | 239 ++++++++++++++++++ core/light_controller.py | 1 + docs/codex/PROJECT_CONTEXT.md | 5 + ...2-public-release-and-rgbic-beta-roadmap.md | 15 +- ...-27-04-home-targeting-dev-virtual-bulbs.md | 50 ++++ localization/catalogs/en.py | 11 + localization/catalogs/es.py | 11 + main.py | 27 +- tests/test_dev_virtual_lights.py | 71 ++++++ tests/test_target_selection.py | 15 ++ tests/test_target_selector.py | 87 +++++++ tests/test_ui_state_dispatch.py | 20 ++ ui/components/dev_virtual_bulbs.py | 146 +++++++++++ ui/components/home_panel.py | 31 ++- ui/components/target_selector.py | 58 ++++- 15 files changed, 764 insertions(+), 23 deletions(-) create mode 100644 core/dev_virtual_lights.py create mode 100644 docs/codex/queries/2026-08-27-04-home-targeting-dev-virtual-bulbs.md create mode 100644 tests/test_dev_virtual_lights.py create mode 100644 tests/test_target_selector.py create mode 100644 ui/components/dev_virtual_bulbs.py 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 f173d1c..8d5eebc 100644 --- a/core/light_controller.py +++ b/core/light_controller.py @@ -533,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/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 53fd68c..4ac07a2 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -69,6 +69,11 @@ implemente su propio protocolo, discovery o controlador. como ventana temporal independiente permanece aprobado pero diferido. - **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; todavía no existe descarga, instalación, reinicio ni rollback automático. 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 index f136ba4..3396f3e 100644 --- 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 @@ -133,6 +133,11 @@ validation must not delay the Windows stable artifact. ## 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. @@ -365,11 +370,5 @@ identifiers, `.venv`, `build` or `dist` without inspecting them explicitly. ## 14. Immediate next block -Begin Phase B with a focused audit of `TargetSelector` and Home: - -- freeze the current one/selected/all contract in component tests; -- define concise Spanish and English labels for partial selection; -- improve empty, single, partial and all visual states; -- preserve the working controller and protocol path; -- validate the result locally before making any updater UI or release metadata - change. +Begin Phase C: integrate the existing read-only release client into Settings, +with deterministic no-network tests and no installation behavior. 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/localization/catalogs/en.py b/localization/catalogs/en.py index 0e9bcaf..fff1c4c 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -3,6 +3,12 @@ CATALOG: dict[str, str] = { # Application / navigation "app.name": "WizZ Desktop", + "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", @@ -189,6 +195,11 @@ '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', diff --git a/localization/catalogs/es.py b/localization/catalogs/es.py index f51f1ea..8b62a62 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -3,6 +3,12 @@ CATALOG: dict[str, str] = { # Aplicación / navegación "app.name": "WizZ Desktop", + "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", @@ -188,6 +194,11 @@ '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', diff --git a/main.py b/main.py index c71648d..10d3e29 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import atexit +import asyncio import os import sys import time @@ -12,6 +13,7 @@ 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 @@ -67,6 +69,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: @@ -109,7 +128,13 @@ def main(page: ft.Page): 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()) 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_target_selection.py b/tests/test_target_selection.py index 886da07..e610bb7 100644 --- a/tests/test_target_selection.py +++ b/tests/test_target_selection.py @@ -8,6 +8,7 @@ def _controller_for_targets(mode, selected, reachable): 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 @@ -46,3 +47,17 @@ def test_single_mode_keeps_only_active_light(): ) 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_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/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/target_selector.py b/ui/components/target_selector.py index fd6fff7..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,6 +29,7 @@ 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, @@ -65,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( @@ -93,14 +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), + 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), ), ) @@ -146,8 +155,6 @@ 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 len(self.selected_targets) > 1 and hasattr(self.wiz, "set_target_selection"): self.wiz.set_target_selection(self.selected_targets) @@ -157,17 +164,45 @@ def _apply_targets(self): 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: From 67ea2a006aa025a5bc9503519d930963387089ec Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 00:12:51 -0400 Subject: [PATCH 11/39] feat: add safe update notification --- docs/codex/PROJECT_CONTEXT.md | 3 +- ...2-public-release-and-rgbic-beta-roadmap.md | 7 +- .../2026-08-28-05-safe-update-notification.md | 42 +++++ localization/catalogs/en.py | 10 ++ localization/catalogs/es.py | 10 ++ tests/test_settings_updates.py | 60 +++++++ ui/components/settings_panel.py | 155 +++++++++++++++++- 7 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 docs/codex/queries/2026-08-28-05-safe-update-notification.md create mode 100644 tests/test_settings_updates.py diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 4ac07a2..955e49c 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -75,7 +75,8 @@ implemente su propio protocolo, discovery o controlador. 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; todavía no existe descarga, instalación, reinicio ni + 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 mediante el storage por usuario que Flet expone en runtime; los datos sobreviven al reemplazo del build. 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 index 3396f3e..4688a60 100644 --- 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 @@ -179,6 +179,10 @@ is not release evidence because Flet desktop repaint timing remains irregular. ## 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 @@ -370,5 +374,4 @@ identifiers, `.venv`, `build` or `dist` without inspecting them explicitly. ## 14. Immediate next block -Begin Phase C: integrate the existing read-only release client into Settings, -with deterministic no-network tests and no installation behavior. +Begin Phase D: Windows release hardening and reproducible artifact validation. 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/localization/catalogs/en.py b/localization/catalogs/en.py index fff1c4c..03e7b47 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -3,6 +3,16 @@ 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.", diff --git a/localization/catalogs/es.py b/localization/catalogs/es.py index 8b62a62..0d1292b 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -3,6 +3,16 @@ 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.", diff --git a/tests/test_settings_updates.py b/tests/test_settings_updates.py new file mode 100644 index 0000000..9ed1332 --- /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.0", + notes_url="https://github.com/yvvvl/WizzController/releases/tag/v1.2.0", + ) + + 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.0" in panel.update_status.value + + +def test_untrusted_release_link_is_not_exposed_to_the_user(): + panel = _panel() + release = ReleaseInfo(version="1.2.0", 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/ui/components/settings_panel.py b/ui/components/settings_panel.py index 71ae38f..4b58c53 100644 --- a/ui/components/settings_panel.py +++ b/ui/components/settings_panel.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import threading +from urllib.parse import urlparse import flet as ft from localization import ( @@ -11,9 +13,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 +28,16 @@ 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): 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._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()) @@ -334,6 +341,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 +402,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}, From 6b806b9947cc92b9ff68c45b38c369b23a4b6cd6 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 00:27:36 -0400 Subject: [PATCH 12/39] fix: remove quick panel from public tray flow --- core/background/tray_service.py | 34 +++++------------ docs/codex/PROJECT_CONTEXT.md | 7 ++-- ...2-public-release-and-rgbic-beta-roadmap.md | 11 +++--- ...6-08-28-06-remove-quick-panel-from-v1-2.md | 35 +++++++++++++++++ main.py | 38 +++---------------- tests/test_quick_panel.py | 8 ++-- tests/test_tray_window_restore.py | 21 +++------- 7 files changed, 67 insertions(+), 87 deletions(-) create mode 100644 docs/codex/queries/2026-08-28-06-remove-quick-panel-from-v1-2.md diff --git a/core/background/tray_service.py b/core/background/tray_service.py index ddc441b..f3c8a5a 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -279,7 +279,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, ) ) @@ -493,23 +495,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.""" - - if not callable(self.on_open_quick): - if os.name != "nt": - return self.toggle_window() + """Handle the tray primary click. - 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 + 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. + """ - return self.toggle_window() if should_toggle else False + if not callable(self.on_open_quick): + return self.show_window() if os.name != "nt": return self._open_quick_from_tray() @@ -670,14 +664,6 @@ def show_window(self) -> bool: pystray. Después se sincroniza el modelo de Flet de forma oportunista. """ - # The Quick Panel reuses the main native window. A tray activation - # must leave compact mode before restoring/focusing it. - quick_controller = getattr(self.page, "_wizz_quick_panel", None) - if getattr(quick_controller, "window_mode", "full") in {"quick", "hidden"}: - open_full = getattr(quick_controller, "open_full", None) - if callable(open_full): - return bool(open_full()) - now = time.monotonic() if now - self._last_show_request < 0.12: return self._last_show_ok diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 955e49c..55cfb3a 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -64,9 +64,10 @@ 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:** release pública Windows `v1.2.0`. -- **Estado de Quick Panel:** foundation y UI compacta implementadas; el - comportamiento actual se estabiliza para la release, mientras el rediseño - como ventana temporal independiente permanece aprobado pero diferido. +- **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 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 index 4688a60..fbda487 100644 --- 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 @@ -77,12 +77,11 @@ existing experimental RGBIC foundation: ### Deferred design work -The separate temporary Quick Panel window remains an approved design in -`docs/codex/queries/2026-08-27-02-quick-panel-window-redesign.md`. It is not a -v1.2.0 release gate because Flet 0.85.2 does not currently provide a validated -second-window path for this application. The current functional panel is -stabilized; the architectural replacement continues on an isolated future -branch. +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 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/main.py b/main.py index 10d3e29..fc77c73 100644 --- a/main.py +++ b/main.py @@ -19,14 +19,12 @@ 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) @@ -37,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 {})) @@ -152,19 +144,6 @@ def shutdown_services(): _safe(wiz.stop) app = WizzApp(page, wiz, hotkeys_manager=hotkeys) - content_host = ft.Container(content=app, expand=True) - quick_controller = QuickPanelController( - page, - wiz, - app, - content_host, - ) - quick_view = QuickPanelView( - quick_controller, - wiz, - i18n=i18n, - ) - quick_controller.attach_view(quick_view) page.on_resize = app.handle_page_resize # PHASE32_RUNTIME_TRAY_SAFE tray = None @@ -178,8 +157,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: @@ -223,22 +200,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/tests/test_quick_panel.py b/tests/test_quick_panel.py index 153d4f6..a8b3d03 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: diff --git a/tests/test_tray_window_restore.py b/tests/test_tray_window_restore.py index f978d95..47d166a 100644 --- a/tests/test_tray_window_restore.py +++ b/tests/test_tray_window_restore.py @@ -182,43 +182,32 @@ 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, - "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, - "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] From 7775abe2e7345835b6dae72dfa4eb8f02aba8f9f Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 00:55:23 -0400 Subject: [PATCH 13/39] fix: store packaged Windows data in local appdata --- config/paths.py | 34 +++++++++++++------ docs/codex/PROJECT_CONTEXT.md | 10 +++--- ...2-public-release-and-rgbic-beta-roadmap.md | 4 +-- tests/test_app_paths.py | 20 +++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/config/paths.py b/config/paths.py index ffbc735..dcca24d 100644 --- a/config/paths.py +++ b/config/paths.py @@ -64,8 +64,18 @@ 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 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) + + # Other packaged platforms honor Flet's explicit storage directory. if flet_storage: target = Path(flet_storage).expanduser().resolve() / "config" return _prepare(target, migrate=True) @@ -76,15 +86,6 @@ def config_dir() -> Path: target = Path(xdg_config) / APP_ARTIFACT / "config" return _prepare(target, migrate=True) - # Windows packaged builds must keep user data outside the install - # directory so upgrades do not overwrite configuration. - 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) - # 3. Modo Desarrollo Local (Guarda en el propio repo config/json) target = project_root() / "config" / "json" return _prepare(target, migrate=False) @@ -94,7 +95,12 @@ 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: + 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 flet_storage: target = Path(flet_storage).expanduser().resolve() / "logs" elif sys.platform.startswith("linux") and is_flet_build(): xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) @@ -176,6 +182,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", diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 55cfb3a..294d638 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -1,6 +1,6 @@ # WizZ Desktop — contexto maestro del proyecto -Última actualización: 2026-08-27 +Última actualización: 2026-08-28 ## Nota de auditoría del checkout actual @@ -12,9 +12,11 @@ como publicada hasta completar sus gates. 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. El runtime Flet de -Windows guarda datos por usuario mediante `FLET_APP_STORAGE_DATA`; no se debe -suponer una ruta absoluta fija ni incluir JSON reales en el artefacto. +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 conserva estado beta/foundation: detección de capacidades, autostart XDG, apertura de carpetas y tray AppIndicator fueron probados de forma 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 index fbda487..d22cc50 100644 --- 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 @@ -55,8 +55,8 @@ existing experimental RGBIC foundation: - Windows stable runtime and packaging. - Transient multi-target selection already implemented in commit `93c0136`. -- Packaged Flet persistence already validated through - `FLET_APP_STORAGE_DATA`. +- 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. diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index c3ff232..4bc07e7 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -31,6 +31,7 @@ def test_flet_storage_migrates_legacy_json_once(monkeypatch, tmp_path): monkeypatch.delenv("WIZZ_CONFIG_DIR", raising=False) monkeypatch.setenv("FLET_APP_STORAGE_DATA", str(storage)) monkeypatch.setenv("WIZZ_LEGACY_CONFIG_DIR", str(legacy)) + monkeypatch.setattr(paths.sys, "platform", "linux") _reset_paths() target = paths.config_dir() @@ -64,6 +65,25 @@ def test_windows_frozen_build_uses_local_appdata(monkeypatch, tmp_path): 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) From 12c794df18ccb2b49813bf6f2b5877938cd22839 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:00:21 -0400 Subject: [PATCH 14/39] docs: record Windows AppData validation --- ...2-public-release-and-rgbic-beta-roadmap.md | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 index d22cc50..64b2058 100644 --- 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 @@ -236,7 +236,7 @@ git diff --check - Home selection states; - tray open, hide, restore and real exit; - global hotkey registration and execution; -- current Quick Panel open/close and full-window restore; +- 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; @@ -248,7 +248,7 @@ git diff --check - 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 the Flet-managed user data directory. +- Confirm configuration remains in `%LOCALAPPDATA%\\WizZDesktop`. - Confirm third-party notices and required assets are present. ### Exit gate @@ -257,6 +257,25 @@ git diff --check - 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. +- 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. + +Remaining manual checks: full single-light control, global hotkey execution, +Home selection states, settings/favorites persistence after restart, and a +clean-extraction launch outside the repository. + ## 9. Phase E — v1.2.0 release candidate ### Version and public documentation @@ -373,4 +392,5 @@ identifiers, `.venv`, `build` or `dist` without inspecting them explicitly. ## 14. Immediate next block -Begin Phase D: Windows release hardening and reproducible artifact validation. +Finish the remaining Phase D manual checks, then prepare v1.2.0 metadata and +release material in Phase E. From e546f6fb47f7a8d09d43dca25700ef661796d206 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:08:24 -0400 Subject: [PATCH 15/39] docs: record real Windows WiZ validation --- ...2-public-release-and-rgbic-beta-roadmap.md | 12 +++-- ...26-08-28-07-windows-real-wiz-validation.md | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 docs/codex/queries/2026-08-28-07-windows-real-wiz-validation.md 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 index 64b2058..90198f9 100644 --- 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 @@ -269,12 +269,18 @@ git diff --check - 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 Quick Panel was removed from the active release flow after real tray, focus and geometry failures; it is not a Phase D smoke requirement. -Remaining manual checks: full single-light control, global hotkey execution, -Home selection states, settings/favorites persistence after restart, and a -clean-extraction launch outside the repository. +Remaining manual checks: global hotkey execution, Home selection states, +settings/favorites persistence after restart, and a clean-extraction launch +outside the repository. ## 9. Phase E — v1.2.0 release candidate 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. From 03d1d0b73eb355b84a80c8dba7e5c4679cb27626 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:20:59 -0400 Subject: [PATCH 16/39] docs: record Windows hotkey smoke validation --- ...026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 90198f9..cf4393a 100644 --- 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 @@ -275,12 +275,13 @@ git diff --check - 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. - 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. -Remaining manual checks: global hotkey execution, Home selection states, -settings/favorites persistence after restart, and a clean-extraction launch -outside the repository. +Remaining manual checks: Home selection states, settings/favorites persistence +after restart, and a clean-extraction launch outside the repository. ## 9. Phase E — v1.2.0 release candidate From 0cbd277cde5cf41e45788c39153a4db0c5e35496 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:40:46 -0400 Subject: [PATCH 17/39] docs: record clean Windows candidate validation --- ...026-08-27-v1.2-public-release-and-rgbic-beta-roadmap.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index cf4393a..5838b55 100644 --- 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 @@ -277,11 +277,14 @@ git diff --check 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 Quick Panel was removed from the active release flow after real tray, focus and geometry failures; it is not a Phase D smoke requirement. -Remaining manual checks: Home selection states, settings/favorites persistence -after restart, and a clean-extraction launch outside the repository. +Remaining manual checks: Home selection states and settings/favorites +persistence after restart. ## 9. Phase E — v1.2.0 release candidate From a562b14d093269e9c232d8aa2c7bbf5817795ed2 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:55:09 -0400 Subject: [PATCH 18/39] docs: close Windows release hardening phase --- ...7-v1.2-public-release-and-rgbic-beta-roadmap.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index 5838b55..8d72dce 100644 --- 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 @@ -215,6 +215,8 @@ the installer, integrity and rollback model are designed. ## 8. Phase D — Windows release hardening +Status: Completed on 2026-08-28. + ### Automated validation Run from the project virtual environment: @@ -280,11 +282,16 @@ git diff --check - 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. -Remaining manual checks: Home selection states and settings/favorites -persistence after restart. +No remaining Phase D release-blocking checks. ## 9. Phase E — v1.2.0 release candidate @@ -402,5 +409,4 @@ identifiers, `.venv`, `build` or `dist` without inspecting them explicitly. ## 14. Immediate next block -Finish the remaining Phase D manual checks, then prepare v1.2.0 metadata and -release material in Phase E. +Begin Phase E: prepare v1.2.0 metadata, changelog and release material. From b081e2cd163d717f0751a57b978750b7f95493ee Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 01:58:11 -0400 Subject: [PATCH 19/39] release: prepare WizZ Desktop v1.2.0 --- CHANGELOG.md | 33 ++++++++++++++++++++++++ README.md | 47 +++++++++++++++++++--------------- app_meta.py | 4 +-- docs/codex/PROJECT_CONTEXT.md | 16 +++++++----- pyproject.toml | 4 +-- tests/test_settings_updates.py | 8 +++--- 6 files changed, 77 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d761fc1..d5b0f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # 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. + +### 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. + +### 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. + +### Validation + +- 361 pruebas automatizadas completadas durante la preparación del release. +- Validación Windows real: bandeja, hotkeys, instancia única, AppData, + extracción limpia y control de una ampolleta WiZ por LAN. + +> 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..6ce1adc 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,19 @@ Las acciones normales se envían por **UDP LAN nativo**, por lo que el control n 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. -> 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. + +> RGBIC, Screen Sync, streaming y actualización automática no están incluidos +> en esta versión estable. --- @@ -84,7 +83,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. @@ -112,7 +111,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`. @@ -123,13 +122,13 @@ La descarga incluye un archivo `.sha256` para comprobar la integridad del paquet ### 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 ``` --- @@ -208,8 +207,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 @@ -233,7 +232,14 @@ 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. Puedes abrir las ubicaciones reales desde: @@ -320,7 +326,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/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 294d638..a79bc1a 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -5,10 +5,10 @@ ## Nota de auditoría del checkout actual La rama activa es `release/v1.2.0-windows`. El baseline documentado para la -planificación de release es `f158d30`, con el working tree limpio después de -validar el build empaquetado de Windows. La versión pública estable continúa -siendo `v1.1.0`; `v1.2.0` todavía es un release candidate y no debe anunciarse -como publicada hasta completar sus gates. +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 @@ -65,7 +65,8 @@ 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:** release pública Windows `v1.2.0`. +- **Desarrollo principal declarado:** preparación de metadata y artefacto para + release pública Windows `v1.2.0` (build 2). - **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 @@ -81,8 +82,9 @@ implemente su propio protocolo, discovery o controlador. 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 mediante el storage por usuario que - Flet expone en runtime; los datos sobreviven al reemplazo del build. +- **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 diff --git a/pyproject.toml b/pyproject.toml index 8b6bcd2..4acb52d 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" @@ -21,7 +21,7 @@ dependencies = [ product = "WizZ Desktop" company = "yvvvl" copyright = "Copyright © 2026 Ignacio" -build_number = 1 +build_number = 2 org = "io.github.yvvvl" [tool.flet.windows] diff --git a/tests/test_settings_updates.py b/tests/test_settings_updates.py index 9ed1332..a491a0f 100644 --- a/tests/test_settings_updates.py +++ b/tests/test_settings_updates.py @@ -21,8 +21,8 @@ def _panel() -> SettingsPanel: def test_available_update_is_exposed_only_with_official_release_link(): panel = _panel() release = ReleaseInfo( - version="1.2.0", - notes_url="https://github.com/yvvvl/WizzController/releases/tag/v1.2.0", + version="1.2.1", + notes_url="https://github.com/yvvvl/WizzController/releases/tag/v1.2.1", ) panel._apply_update_result(release) @@ -30,12 +30,12 @@ def test_available_update_is_exposed_only_with_official_release_link(): assert panel.btn_check_updates.disabled is False assert panel.btn_open_release.visible is True assert panel._available_release == release - assert "1.2.0" in panel.update_status.value + 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.0", notes_url="https://example.test/download") + release = ReleaseInfo(version="1.2.1", notes_url="https://example.test/download") panel._apply_update_result(release) From bc027d7213e53a14f8fbfc6bee2aaf928447f22d Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 02:08:29 -0400 Subject: [PATCH 20/39] docs: record v1.2.0 release candidate artifact --- ...2-public-release-and-rgbic-beta-roadmap.md | 17 ++++++++ ...8-28-08-v1-2-release-candidate-artifact.md | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 docs/codex/queries/2026-08-28-08-v1-2-release-candidate-artifact.md 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 index 8d72dce..b0e4c68 100644 --- 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 @@ -295,6 +295,8 @@ 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. @@ -316,6 +318,21 @@ No remaining Phase D release-blocking checks. 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. + ### Definition of done - The public artifact launches and controls a real WiZ bulb. 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. From 70dc2260d3e4d1e5deadba5c55889555f34e1499 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 02:30:19 -0400 Subject: [PATCH 21/39] feat: wire Linux beta desktop integration --- .github/workflows/linux-beta-build.yml | 51 ++++++++ README.md | 49 +++++++- config/app_runtime_manager.py | 58 +++++++-- core/background/tray_service.py | 20 ++++ docs/codex/PROJECT_CONTEXT.md | 14 ++- ...2-public-release-and-rgbic-beta-roadmap.md | 43 +++++-- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 57 +++++++++ localization/catalogs/en.py | 3 + localization/catalogs/es.py | 3 + main.py | 7 +- pyproject.toml | 3 + scripts/build_linux.sh | 110 ++++++++++++++++++ tests/test_app_runtime_manager.py | 52 +++++++++ tests/test_platform_linux.py | 26 +++++ ui/app.py | 10 +- ui/components/settings_panel.py | 26 ++++- 16 files changed, 498 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/linux-beta-build.yml create mode 100644 docs/codex/queries/2026-08-28-09-linux-v1-2-beta-delivery.md create mode 100644 scripts/build_linux.sh diff --git a/.github/workflows/linux-beta-build.yml b/.github/workflows/linux-beta-build.yml new file mode 100644 index 0000000..6908003 --- /dev/null +++ b/.github/workflows/linux-beta-build.yml @@ -0,0 +1,51 @@ +name: Linux beta build + +on: + push: + branches: + - feature/v1.2.0-linux-beta + 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: + python-version: "3.13" + + - name: Install Python dependencies + run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt + + - 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 \ + 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: Test and package Linux beta + run: bash scripts/build_linux.sh --output dist/linux + + - 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/README.md b/README.md index 6ce1adc..553bff9 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,7 +24,9 @@ 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. > Candidato de release actual: **v1.2.0 · build 2** @@ -36,6 +38,8 @@ La aplicación está diseñada para Windows y combina control de iluminación, a - 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. @@ -103,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. @@ -119,6 +123,17 @@ 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 publicará como `WizZDesktop-v1.2.0-linux-x64.tar.gz` con su +archivo `.sha256`. Extrae el archivo y ejecuta `WizZDesktop` desde la carpeta +extraída. La primera validación objetivo es Ubuntu Desktop con GNOME; en +Wayland, la posición de ventana la decide el compositor. + +La bandeja requiere que el escritorio soporte AppIndicator. Si no está +disponible, la aplicación sigue siendo usable como ventana normal; no dependas +de la bandeja para navegar. + ### Verificar SHA-256 en PowerShell ```powershell @@ -151,7 +166,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 @@ -222,6 +238,27 @@ La guía completa está en --- +## Build nativa para Linux beta + +En Ubuntu instala las dependencias de Flet indicadas en su guía oficial y crea +el entorno Python del proyecto. Luego 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/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. @@ -241,6 +278,10 @@ En el ejecutable Windows, configuraciones y logs se guardan en: 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: ```text diff --git a/config/app_runtime_manager.py b/config/app_runtime_manager.py index 776bf4a..455d467 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 os.name == "nt": + 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 executable = pythonw if pythonw.exists() else py - return subprocess.list2cmdline([str(executable), str(main_py)]) + if os.name == "nt": + 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 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 os.name != "nt": - if self.data.get("startup_with_windows"): - self.data["startup_with_windows"] = False - self._save_dict(self.data) return if not bool(self.data.get("startup_with_windows")): @@ -137,6 +148,24 @@ 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]: + """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 os.name != "nt": with self._lock: self.data["startup_with_windows"] = False @@ -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/core/background/tray_service.py b/core/background/tray_service.py index f3c8a5a..d062e7f 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) diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index a79bc1a..7b0137d 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -746,7 +746,8 @@ exacta empaquetada. - 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; -- completar suite, build, smoke manual y artefacto Windows de `v1.2.0`; +- 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; @@ -757,7 +758,8 @@ exacta empaquetada. - continuar el Quick Panel como ventana temporal independiente en una rama aislada, después de validar un mecanismo nativo compatible; -- completar wiring, lifecycle y packaging Linux antes del estado beta; +- 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; @@ -853,14 +855,16 @@ pertenecen a `docs/third-party/`. ### Cross-platform -- `core/platform/` contiene contratos, fakes y servicios Linux aislados; el - runtime principal todavía no los conecta como soporte de producto; +- `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 index b0e4c68..c1b73d6 100644 --- 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 @@ -12,7 +12,8 @@ Baseline commit: `f158d30` Deliver the next version through two intentionally separate channels: -1. a stable public Windows release focused on reliable everyday control; and +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. @@ -54,6 +55,8 @@ existing experimental RGBIC foundation: ### 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. @@ -70,7 +73,6 @@ existing experimental RGBIC foundation: - A new installer technology or signing pipeline. - The independent Quick Panel native-window redesign. - A UI-engine migration or PySide6/C++ rewrite. -- Linux product wiring or package publication. - macOS implementation, CI or support claims. - Refactors of `LightController`, `WizProtocol`, action sequences or persisted favorites that are unrelated to a demonstrated release defect. @@ -106,12 +108,14 @@ Current clean baseline: release/v1.2.0-windows @ f158d30 +-----------+-----------+ | | v v - Linux beta continuation RGBIC closed beta branch + 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. Linux -validation must not delay the Windows stable artifact. +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 @@ -333,12 +337,28 @@ Status: Release candidate prepared on 2026-08-28; publication pending owner appr 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 the release artifact identify the same version. +- `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 @@ -379,15 +399,16 @@ 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 continuation +## 11. Linux beta implementation -Linux remains a separate post-v1.2 workstream: +Linux is now an in-progress v1.2.0 workstream on `feature/v1.2.0-linux-beta`: - preserve the validated platform contracts and fakes; -- finish the main-thread AppIndicator run-loop integration; -- wire services only after lifecycle tests and VM smoke pass; +- 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; -- design Unix single-instance activation before claiming product parity; +- 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. 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..651d958 --- /dev/null +++ b/docs/codex/queries/2026-08-28-09-linux-v1-2-beta-delivery.md @@ -0,0 +1,57 @@ +# Linux v1.2.0 beta delivery + +Status: In Progress + +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. +- Settings uses the platform system service to open data and log folders; the + Linux fallback is `xdg-open`. +- 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. +- The existing file-lock single-instance exclusion is retained for beta. It + prevents duplicate controllers even where foreground activation is not yet + portable. + +## Validation required before release + +1. Run the full automated suite on Ubuntu Desktop. +2. Launch the source app on GNOME Wayland and confirm normal UI, tray menu, + hide/restore and real exit. +3. Confirm Settings > Datos and Logs opens the XDG storage folders. +4. Toggle start at login and inspect the generated desktop entry. +5. Build natively with `flet build linux`, launch the extracted output outside + the repository and repeat the smoke checks. +6. Perform WiZ discovery and a controlled power/brightness/RGB/Kelvin/scene + smoke on a Linux LAN when compatible hardware is available. +7. Produce an archive plus SHA-256 only after the above evidence is recorded. + +## 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. +- macOS support. diff --git a/localization/catalogs/en.py b/localization/catalogs/en.py index 03e7b47..4af3d9d 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -187,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 @@ -222,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', @@ -290,6 +292,7 @@ "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.linux_unavailable": "Automatic startup is unavailable on this Linux desktop.", "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.", diff --git a/localization/catalogs/es.py b/localization/catalogs/es.py index 0d1292b..0598eb0 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -186,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 @@ -221,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', @@ -289,6 +291,7 @@ "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.linux_unavailable": "El inicio automático no está disponible en este escritorio Linux.", "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.", diff --git a/main.py b/main.py index fc77c73..91a902f 100644 --- a/main.py +++ b/main.py @@ -143,7 +143,12 @@ def shutdown_services(): _safe(hotkeys.stop) _safe(wiz.stop) - app = WizzApp(page, wiz, hotkeys_manager=hotkeys) + app = WizzApp( + page, + wiz, + hotkeys_manager=hotkeys, + platform_services=platform_runtime.services, + ) page.on_resize = app.handle_page_resize # PHASE32_RUNTIME_TRAY_SAFE tray = None diff --git a/pyproject.toml b/pyproject.toml index 4acb52d..c0e1bf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ org = "io.github.yvvvl" [tool.flet.windows] artifact = "WizZDesktop" +[tool.flet.linux] +artifact = "WizZDesktop" + [tool.flet.app] path = "." module = "main" diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh new file mode 100644 index 0000000..55fdcbf --- /dev/null +++ b/scripts/build_linux.sh @@ -0,0 +1,110 @@ +#!/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. +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 + +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" +"$PYTHON" -m flet build linux . --output "$OUTPUT_DIR" --yes --no-rich-output + +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 + +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/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_platform_linux.py b/tests/test_platform_linux.py index ca39577..3df2b01 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,6 +10,7 @@ LinuxWindowService, detect_linux_capabilities, ) +from core.background.tray_service import TrayService def test_linux_capabilities_are_explicit_and_do_not_touch_network(monkeypatch): @@ -175,3 +178,26 @@ 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" diff --git a/ui/app.py b/ui/app.py index 188376e..078d68b 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) @@ -52,6 +59,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), ] diff --git a/ui/components/settings_panel.py b/ui/components/settings_panel.py index 4b58c53..b558a97 100644 --- a/ui/components/settings_panel.py +++ b/ui/components/settings_panel.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import sys +import subprocess import threading from urllib.parse import urlparse @@ -28,7 +30,16 @@ 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, release_client=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() @@ -36,6 +47,7 @@ def __init__(self, wiz, *, i18n=None, on_language_change=None, runtime=None, rel 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) @@ -256,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( @@ -543,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) From 3c277e6224140e01fa0e04403e60ee9ea7b6d653 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 02:54:57 -0400 Subject: [PATCH 22/39] fix: use visible GNOME tray icon --- core/background/tray_service.py | 17 +++++++++++++++++ tests/test_platform_linux.py | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/core/background/tray_service.py b/core/background/tray_service.py index d062e7f..b1aedba 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -408,6 +408,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 @@ -428,6 +435,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) diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index 3df2b01..f897e55 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -201,3 +201,19 @@ def test_tray_service_keeps_explicit_linux_backend_choice(monkeypatch): 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 From 859fdb43a03a9b7c8e54d27964109b305a378f6d Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 03:00:36 -0400 Subject: [PATCH 23/39] fix: run Linux tray loop independently --- core/background/tray_service.py | 25 +++++++++++++++---- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 3 +++ tests/test_platform_linux.py | 23 +++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/core/background/tray_service.py b/core/background/tray_service.py index b1aedba..542622d 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -189,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 @@ -206,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 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 index 651d958..948c08d 100644 --- 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 @@ -24,6 +24,9 @@ explicitly beta; macOS stays deferred. - 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`. - The existing persisted `startup_with_windows` key is retained for v1.1 data diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index f897e55..9d3bef4 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -217,3 +217,26 @@ def test_linux_indicator_icon_is_compact_and_opaque(monkeypatch): 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 From 74f3d7ba1787c95a4dafb325bda6c69bfc4bfa52 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 03:27:01 -0400 Subject: [PATCH 24/39] fix: disable unsafe Linux global hotkeys --- config/hotkeys_manager.py | 16 ++++++++ core/background/tray_service.py | 2 +- core/platform/linux.py | 11 +++--- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 9 ++++- localization/catalogs/en.py | 3 ++ localization/catalogs/es.py | 3 ++ tests/test_hotkeys_manager.py | 16 ++++++++ tests/test_platform_linux.py | 2 +- ui/app.py | 17 +++++++- ui/components/hotkeys_panel.py | 39 +++++++++++++++++++ 10 files changed, 109 insertions(+), 9 deletions(-) diff --git a/config/hotkeys_manager.py b/config/hotkeys_manager.py index 9776acc..01e4087 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,14 +140,25 @@ def _migrate(self) -> None: @property def available(self) -> bool: """True si existe algún backend global utilizable.""" + # ``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 os.name == "nt" 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 sys.platform.startswith("linux"): + return self._t("hotkeys.dependency.linux_unavailable") if os.name == "nt": if _keyboard is None: return self._t("hotkeys.dependency.native_recording_missing", error=_IMPORT_ERROR) @@ -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: diff --git a/core/background/tray_service.py b/core/background/tray_service.py index 542622d..2429101 100644 --- a/core/background/tray_service.py +++ b/core/background/tray_service.py @@ -393,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", 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/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 index 948c08d..7b22e5d 100644 --- 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 @@ -34,6 +34,13 @@ explicitly beta; macOS stays deferred. 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 existing file-lock single-instance exclusion is retained for beta. It prevents duplicate controllers even where foreground activation is not yet portable. @@ -56,5 +63,5 @@ explicitly beta; macOS stays deferred. - 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. + hotkeys on Wayland. Running WizZ with `sudo` is not a supported workaround. - macOS support. diff --git a/localization/catalogs/en.py b/localization/catalogs/en.py index 4af3d9d..6f84911 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -515,6 +515,9 @@ "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.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 0598eb0..2ed9fba 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -514,6 +514,9 @@ "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.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/tests/test_hotkeys_manager.py b/tests/test_hotkeys_manager.py index bb2e2ff..1a8bc17 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) diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index 9d3bef4..5717aa1 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -20,7 +20,7 @@ def test_linux_capabilities_are_explicit_and_do_not_touch_network(monkeypatch): 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 diff --git a/ui/app.py b/ui/app.py index 078d68b..025fc98 100644 --- a/ui/app.py +++ b/ui/app.py @@ -47,6 +47,9 @@ def __init__( 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), @@ -110,7 +113,7 @@ def __init__( 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(), ], on_change=self._on_nav, ) @@ -137,6 +140,18 @@ def __init__( def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) + def _hotkeys_destination(self): + destination = ft.NavigationRailDestination( + icon=ft.Icons.KEYBOARD_OUTLINED, + selected_icon=ft.Icons.KEYBOARD_ROUNDED, + label=self._t("nav.hotkeys"), + ) + # Flet renders disabled rail destinations in the platform's muted + # color and prevents opening the editor on Linux beta sessions. + if not self.hotkeys_available: + destination.disabled = True + return destination + def set_language_preference(self, preference: str) -> str: normalized = self.language_preference.save(preference) changed = self.i18n.set_preference(normalized) 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( From 7e0628231939f0fa22e19b9d985473ca0b8e66aa Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 03:35:57 -0400 Subject: [PATCH 25/39] feat: adapt Linux desktop messaging and storage --- config/paths.py | 26 ++++++------ .../2026-08-28-09-linux-v1-2-beta-delivery.md | 7 ++++ localization/catalogs/en.py | 11 ++--- localization/catalogs/es.py | 11 ++--- localization/ui.py | 2 +- tests/test_app_paths.py | 16 +++++--- tests/test_language_ui.py | 2 +- ui/app.py | 41 ++++++++++++++----- 8 files changed, 75 insertions(+), 41 deletions(-) diff --git a/config/paths.py b/config/paths.py index dcca24d..2a979ec 100644 --- a/config/paths.py +++ b/config/paths.py @@ -75,17 +75,20 @@ def config_dir() -> Path: target = Path(local_app_data) / 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) - - # 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) @@ -100,16 +103,11 @@ def logs_dir() -> Path: "LOCALAPPDATA", str(Path.home() / "AppData" / "Local") ) target = Path(local_app_data) / APP_ARTIFACT / "logs" + elif sys.platform.startswith("linux") and is_flet_build(): + 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" - 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" - elif 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" else: target = project_root() / "logs" 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 index 7b22e5d..13cea30 100644 --- 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 @@ -29,6 +29,11 @@ explicitly beta; macOS stays deferred. 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. @@ -41,6 +46,8 @@ explicitly beta; macOS stays deferred. 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 rail item uses a muted icon and exposes the reason in a + hover tooltip; selecting it is blocked rather than opening 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. diff --git a/localization/catalogs/en.py b/localization/catalogs/en.py index 6f84911..baaf7d5 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -200,7 +200,7 @@ # 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', @@ -293,10 +293,10 @@ "bulbs.ip_example": "192.168.1.20", "runtime.startup.windows_only": "Start with Windows is only available on Windows.", "runtime.startup.linux_unavailable": "Automatic startup is unavailable on this Linux desktop.", - "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.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", @@ -518,6 +518,7 @@ "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.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 2ed9fba..532f59c 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -199,7 +199,7 @@ # 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', @@ -292,10 +292,10 @@ "bulbs.ip_example": "192.168.1.20", "runtime.startup.windows_only": "Inicio con Windows solo está disponible en Windows.", "runtime.startup.linux_unavailable": "El inicio automático no está disponible en este escritorio Linux.", - "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.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", @@ -517,6 +517,7 @@ "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.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/tests/test_app_paths.py b/tests/test_app_paths.py index 4bc07e7..b87542a 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -21,21 +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() @@ -44,6 +49,7 @@ 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): 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/ui/app.py b/ui/app.py index 025fc98..acaa470 100644 --- a/ui/app.py +++ b/ui/app.py @@ -141,15 +141,25 @@ def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) def _hotkeys_destination(self): + unavailable = not self.hotkeys_available + reason = self._t("hotkeys.linux_disabled_tooltip") destination = ft.NavigationRailDestination( - icon=ft.Icons.KEYBOARD_OUTLINED, - selected_icon=ft.Icons.KEYBOARD_ROUNDED, - label=self._t("nav.hotkeys"), + icon=( + ft.Icon(ft.Icons.KEYBOARD_OUTLINED, color=Theme.MUTED, tooltip=reason) + if unavailable + else ft.Icons.KEYBOARD_OUTLINED + ), + selected_icon=( + ft.Icon(ft.Icons.KEYBOARD_ROUNDED, color=Theme.MUTED, tooltip=reason) + if unavailable + else ft.Icons.KEYBOARD_ROUNDED + ), + label=( + ft.Text(self._t("nav.hotkeys"), color=Theme.MUTED) + if unavailable + else self._t("nav.hotkeys") + ), ) - # Flet renders disabled rail destinations in the platform's muted - # color and prevents opening the editor on Linux beta sessions. - if not self.hotkeys_available: - destination.disabled = True return destination def set_language_preference(self, preference: str) -> str: @@ -161,8 +171,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): - destination.label = label + for index, (destination, label) in enumerate(zip(self.rail.destinations, labels)): + destination.label = ( + ft.Text(label, color=Theme.MUTED) + if index == 6 and not self.hotkeys_available + else label + ) try: self.page_ref.title = self._t("app.name") @@ -266,7 +280,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. @@ -278,6 +297,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] From 577fb0db412e409f96826a8babd27ce832066c3c Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 03:45:36 -0400 Subject: [PATCH 26/39] fix: make Linux hotkey status non-interactive --- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 6 +- localization/catalogs/en.py | 1 + localization/catalogs/es.py | 1 + ui/app.py | 58 +++++++++++-------- 4 files changed, 39 insertions(+), 27 deletions(-) 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 index 13cea30..5913b11 100644 --- 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 @@ -46,8 +46,10 @@ explicitly beta; macOS stays deferred. 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 rail item uses a muted icon and exposes the reason in a - hover tooltip; selecting it is blocked rather than opening an unusable editor. +- 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. diff --git a/localization/catalogs/en.py b/localization/catalogs/en.py index baaf7d5..0a31bd1 100644 --- a/localization/catalogs/en.py +++ b/localization/catalogs/en.py @@ -519,6 +519,7 @@ "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 532f59c..9eec67a 100644 --- a/localization/catalogs/es.py +++ b/localization/catalogs/es.py @@ -518,6 +518,7 @@ "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/ui/app.py b/ui/app.py index acaa470..1d1de21 100644 --- a/ui/app.py +++ b/ui/app.py @@ -113,8 +113,9 @@ def __init__( label=self._t("nav.routines"), ), ft.NavigationRailDestination(icon=ft.Icons.SETTINGS_OUTLINED, selected_icon=ft.Icons.SETTINGS_ROUNDED, label=self._t("nav.settings")), - self._hotkeys_destination(), + *([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, ) @@ -141,26 +142,33 @@ def _t(self, key: str, **values) -> str: return self.i18n.translate(key, **values) def _hotkeys_destination(self): - unavailable = not self.hotkeys_available - reason = self._t("hotkeys.linux_disabled_tooltip") - destination = ft.NavigationRailDestination( - icon=( - ft.Icon(ft.Icons.KEYBOARD_OUTLINED, color=Theme.MUTED, tooltip=reason) - if unavailable - else ft.Icons.KEYBOARD_OUTLINED - ), - selected_icon=( - ft.Icon(ft.Icons.KEYBOARD_ROUNDED, color=Theme.MUTED, tooltip=reason) - if unavailable - else ft.Icons.KEYBOARD_ROUNDED - ), - label=( - ft.Text(self._t("nav.hotkeys"), color=Theme.MUTED) - if unavailable - else self._t("nav.hotkeys") + 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, ), ) - return destination def set_language_preference(self, preference: str) -> str: normalized = self.language_preference.save(preference) @@ -171,12 +179,12 @@ def set_language_preference(self, preference: str) -> str: def _on_language_changed(self, language: str) -> None: labels = translated_navigation(self.i18n) - for index, (destination, label) in enumerate(zip(self.rail.destinations, labels)): - destination.label = ( - ft.Text(label, color=Theme.MUTED) - if index == 6 and not self.hotkeys_available - else label - ) + 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") From b3d11401defb6a3d7d19398a9c4a1ea19cac04c9 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 04:06:41 -0400 Subject: [PATCH 27/39] fix: invoke Flet CLI in Linux build script --- scripts/build_linux.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 55fdcbf..4f89a60 100644 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -49,6 +49,14 @@ 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 export PYTHONUTF8=1 export PYTHONIOENCODING=utf-8 @@ -71,7 +79,7 @@ if [[ "$SKIP_TESTS" != true ]]; then fi mkdir -p "$RELEASE_DIR" -"$PYTHON" -m flet build linux . --output "$OUTPUT_DIR" --yes --no-rich-output +"$FLET" build linux . --output "$OUTPUT_DIR" --yes --no-rich-output EXECUTABLE="$(find "$OUTPUT_DIR" -type f -name "$ARTIFACT" -perm -u+x -print -quit)" if [[ -z "$EXECUTABLE" ]]; then From 9e46fa1448e1de790ca12c59d480ea2ee8c9629c Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 04:10:08 -0400 Subject: [PATCH 28/39] fix: mark Linux build script executable --- scripts/build_linux.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build_linux.sh diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh old mode 100644 new mode 100755 From 29acf97bd8a2f0950df62cf8d74af9b71f00a777 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 04:13:23 -0400 Subject: [PATCH 29/39] test: simulate hotkey platforms consistently --- tests/test_hotkeys_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_hotkeys_manager.py b/tests/test_hotkeys_manager.py index 1a8bc17..9b2943c 100644 --- a/tests/test_hotkeys_manager.py +++ b/tests/test_hotkeys_manager.py @@ -149,6 +149,7 @@ def test_partial_native_registration_uses_keyboard_only_for_failed(tmp_path, mon 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) @@ -179,6 +180,9 @@ def test_reregister_removes_old_keyboard_handles(tmp_path, monkeypatch): 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) From d48ad7220a47501dbd19ceaf201473c79bb3b6bf Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 11:05:18 -0400 Subject: [PATCH 30/39] test: simulate supported platform for hotkey panel --- tests/test_full_app_i18n.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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) From 966b1ecb2aaeb7d029edd3f32361e288ecf7140f Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 11:07:25 -0400 Subject: [PATCH 31/39] fix: isolate platform detection from global os state --- config/hotkeys_manager.py | 6 +++--- tests/test_hotkeys_manager.py | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/config/hotkeys_manager.py b/config/hotkeys_manager.py index 01e4087..1a758d5 100644 --- a/config/hotkeys_manager.py +++ b/config/hotkeys_manager.py @@ -147,7 +147,7 @@ def available(self) -> bool: # compatible y nunca intenta registrar hooks. if sys.platform.startswith("linux"): return False - return os.name == "nt" or _keyboard is not None + return sys.platform.startswith("win") or _keyboard is not None @property def can_record(self) -> bool: @@ -159,7 +159,7 @@ def can_record(self) -> bool: def dependency_message(self) -> str: if sys.platform.startswith("linux"): return self._t("hotkeys.dependency.linux_unavailable") - if os.name == "nt": + 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") @@ -593,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/tests/test_hotkeys_manager.py b/tests/test_hotkeys_manager.py index 9b2943c..da429a9 100644 --- a/tests/test_hotkeys_manager.py +++ b/tests/test_hotkeys_manager.py @@ -148,7 +148,6 @@ 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) @@ -179,7 +178,6 @@ 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") From a73b22c1e6868e33a3f5498542c690e5c0b58482 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 11:28:16 -0400 Subject: [PATCH 32/39] test: isolate desktop platform simulations on Linux --- config/app_runtime_manager.py | 6 +++--- tests/test_main_instance_activation.py | 1 + tests/test_packaged_startup.py | 4 ++-- tests/test_quick_panel.py | 3 +++ tests/test_tray_window_restore.py | 2 ++ 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/config/app_runtime_manager.py b/config/app_runtime_manager.py index 455d467..67ff896 100644 --- a/config/app_runtime_manager.py +++ b/config/app_runtime_manager.py @@ -103,15 +103,15 @@ def update(self, **kwargs: Any) -> None: def _startup_command(self) -> str: packaged = resolve_packaged_executable() if packaged is not None: - if os.name == "nt": + 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 - if os.name == "nt": + if sys.platform.startswith("win"): return subprocess.list2cmdline([str(executable), str(main_py)]) return shlex.join([str(executable), str(main_py)]) 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_quick_panel.py b/tests/test_quick_panel.py index a8b3d03..87fc33c 100644 --- a/tests/test_quick_panel.py +++ b/tests/test_quick_panel.py @@ -1094,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 @@ -1118,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)) @@ -1134,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_tray_window_restore.py b/tests/test_tray_window_restore.py index 47d166a..11e139a 100644 --- a/tests/test_tray_window_restore.py +++ b/tests/test_tray_window_restore.py @@ -187,6 +187,7 @@ def test_windows_tray_primary_action_restores_main_window_immediately(monkeypatc calls: list[bool] = [] monkeypatch.setattr(tray_module.os, "name", "nt") + monkeypatch.setattr(tray_module.sys, "platform", "win32") monkeypatch.setattr( tray, "show_window", @@ -202,6 +203,7 @@ def test_non_windows_tray_primary_action_restores_main_window_immediately(monkey calls: list[bool] = [] monkeypatch.setattr(tray_module.os, "name", "posix") + monkeypatch.setattr(tray_module.sys, "platform", "linux") monkeypatch.setattr( tray, "show_window", From 68cb67a7c875ca69aace390129b9afee3b751871 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 11:31:30 -0400 Subject: [PATCH 33/39] fix: use portable platform checks for startup --- config/app_runtime_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/app_runtime_manager.py b/config/app_runtime_manager.py index 67ff896..50fd6b0 100644 --- a/config/app_runtime_manager.py +++ b/config/app_runtime_manager.py @@ -132,7 +132,7 @@ def _sync_startup_registration(self) -> None: logging.warning("[Startup] No se pudo sincronizar inicio Linux: %s", exc) return - if os.name != "nt": + if not sys.platform.startswith("win"): return if not bool(self.data.get("startup_with_windows")): @@ -166,7 +166,7 @@ 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) - if os.name != "nt": + if not sys.platform.startswith("win"): with self._lock: self.data["startup_with_windows"] = False self._save_dict(self.data) From 9aad51717e26ed87cbd5dae90c75c69c2ac95c09 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 11:59:15 -0400 Subject: [PATCH 34/39] fix: preflight Linux linker dependency --- scripts/build_linux.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 4f89a60..8abc6d0 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -58,6 +58,21 @@ if [[ -z "$FLET" || ! -x "$FLET" ]]; then 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 + export PYTHONUTF8=1 export PYTHONIOENCODING=utf-8 export FLET_CLI_NO_RICH_OUTPUT=true From 39bce6508afbef34e79df1e48928e0d8de0845c6 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 12:19:53 -0400 Subject: [PATCH 35/39] fix: bundle Linux tray runtime dependencies --- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 3 +++ pyproject.toml | 2 ++ requirements.txt | 4 ++++ scripts/build_linux.sh | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+) 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 index 5913b11..f4bd1d9 100644 --- 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 @@ -53,6 +53,9 @@ explicitly beta; macOS stays deferred. - 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 required before release diff --git a/pyproject.toml b/pyproject.toml index c0e1bf5..ab0e498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ 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] 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 index 8abc6d0..911525a 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -73,6 +73,18 @@ if [[ ! -x "$LLVM_BIN/ld.lld" && ! -x "$LLVM_BIN/ld" ]]; then 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 @@ -96,6 +108,12 @@ 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 From 4841547827915b739f853a8e2445bf1fdaeb5eac Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 12:36:21 -0400 Subject: [PATCH 36/39] docs: prepare v1.2 release material --- .github/workflows/linux-beta-build.yml | 11 ++-- CHANGELOG.md | 11 +++- README.md | 32 +++++++--- docs/codex/PROJECT_CONTEXT.md | 34 ++++++----- .../2026-08-28-09-linux-v1-2-beta-delivery.md | 32 ++++++---- .../2026-08-28-10-v1-2-release-preparation.md | 59 +++++++++++++++++++ 6 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 docs/codex/queries/2026-08-28-10-v1-2-release-preparation.md diff --git a/.github/workflows/linux-beta-build.yml b/.github/workflows/linux-beta-build.yml index 6908003..03254bd 100644 --- a/.github/workflows/linux-beta-build.yml +++ b/.github/workflows/linux-beta-build.yml @@ -23,21 +23,24 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.13" - - - name: Install Python dependencies - run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-build.txt + # 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 libgirepository-2.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b0f10..853bb1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - 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. ### Changed @@ -16,6 +18,8 @@ `%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 @@ -23,12 +27,17 @@ 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 -- 361 pruebas automatizadas completadas durante la preparación del release. +- 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. diff --git a/README.md b/README.md index 553bff9..713644f 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,15 @@ La descarga incluye un archivo `.sha256` para comprobar la integridad del paquet ### Linux beta — Ubuntu Desktop -La beta Linux se publicará como `WizZDesktop-v1.2.0-linux-x64.tar.gz` con su +La beta Linux se distribuye como `WizZDesktop-v1.2.0-linux-x64.tar.gz` con su archivo `.sha256`. Extrae el archivo y ejecuta `WizZDesktop` desde la carpeta -extraída. La primera validación objetivo es Ubuntu Desktop con GNOME; en -Wayland, la posición de ventana la decide el compositor. +extraída. La plataforma validada es Ubuntu Desktop con GNOME; en Wayland, la +posición de ventana la decide el compositor. -La bandeja requiere que el escritorio soporte AppIndicator. Si no está -disponible, la aplicación sigue siendo usable como ventana normal; no dependas -de la bandeja para navegar. +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 @@ -240,8 +241,23 @@ La guía completa está en ## Build nativa para Linux beta -En Ubuntu instala las dependencias de Flet indicadas en su guía oficial y crea -el entorno Python del proyecto. Luego ejecuta: +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 diff --git a/docs/codex/PROJECT_CONTEXT.md b/docs/codex/PROJECT_CONTEXT.md index 7b0137d..8a7f9bb 100644 --- a/docs/codex/PROJECT_CONTEXT.md +++ b/docs/codex/PROJECT_CONTEXT.md @@ -4,11 +4,11 @@ ## Nota de auditoría del checkout actual -La rama activa es `release/v1.2.0-windows`. 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 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 @@ -18,11 +18,13 @@ 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 conserva estado beta/foundation: detección de capacidades, autostart -XDG, apertura de carpetas y tray AppIndicator fueron probados de forma -aislada, pero el wiring productivo y el packaging siguen pendientes. macOS -está diferido. RGBIC continúa como beta cerrada experimental; no forma parte -del soporte público de v1.2.0. +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 @@ -65,8 +67,8 @@ 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:** preparación de metadata y artefacto para - release pública Windows `v1.2.0` (build 2). +- **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 @@ -97,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:** contratos, fakes, detección Linux y servicios - aislados de autostart, carpetas, ventana y tray existen. No hay wiring - productivo Linux, hotkeys Wayland resueltas ni package beta. macOS está - explícitamente diferido. +- **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 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 index f4bd1d9..acfb671 100644 --- 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 @@ -1,6 +1,6 @@ # Linux v1.2.0 beta delivery -Status: In Progress +Status: Validated; publication pending Date: 2026-08-28 @@ -57,18 +57,26 @@ explicitly beta; macOS stays deferred. 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 required before release +## Validation executed -1. Run the full automated suite on Ubuntu Desktop. -2. Launch the source app on GNOME Wayland and confirm normal UI, tray menu, - hide/restore and real exit. -3. Confirm Settings > Datos and Logs opens the XDG storage folders. -4. Toggle start at login and inspect the generated desktop entry. -5. Build natively with `flet build linux`, launch the extracted output outside - the repository and repeat the smoke checks. -6. Perform WiZ discovery and a controlled power/brightness/RGB/Kelvin/scene - smoke on a Linux LAN when compatible hardware is available. -7. Produce an archive plus SHA-256 only after the above evidence is recorded. +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 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. From 72656ada4bb30470e424d759229f733c44a3a9ab Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 17:37:26 -0400 Subject: [PATCH 37/39] fix: make Linux CI dependencies deterministic --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/cross-platform-smoke.yml | 4 ++-- .github/workflows/linux-beta-build.yml | 2 +- tests/test_platform_linux.py | 3 +++ 4 files changed, 13 insertions(+), 3 deletions(-) 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 index 03254bd..0e8dc38 100644 --- a/.github/workflows/linux-beta-build.yml +++ b/.github/workflows/linux-beta-build.yml @@ -31,7 +31,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ binutils clang cmake llvm lld ninja-build pkg-config \ - libcairo2-dev libgirepository-2.0-dev \ + 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 \ diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index 5717aa1..16cfc63 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -17,6 +17,9 @@ 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 From a376e3a92d4e3ed5bfabdb011d5aa577152c8561 Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 18:10:35 -0400 Subject: [PATCH 38/39] feat: add Linux desktop launcher installer --- CHANGELOG.md | 2 + README.md | 17 +++++++-- scripts/build_linux.sh | 16 +++++++- scripts/linux_install.sh | 75 ++++++++++++++++++++++++++++++++++++++ scripts/linux_uninstall.sh | 30 +++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100755 scripts/linux_install.sh create mode 100755 scripts/linux_uninstall.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 853bb1b..e91d54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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 diff --git a/README.md b/README.md index 713644f..203eee3 100644 --- a/README.md +++ b/README.md @@ -126,9 +126,18 @@ La descarga incluye un archivo `.sha256` para comprobar la integridad del paquet ### 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 y ejecuta `WizZDesktop` desde la carpeta -extraída. La plataforma validada es Ubuntu Desktop con GNOME; en Wayland, la -posición de ventana la decide el compositor. +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 @@ -269,6 +278,8 @@ 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 ``` diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index 911525a..20c299a 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -14,7 +14,8 @@ 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. +a SHA-256 checksum under dist/release. The archive includes install.sh, which +installs a launcher for the current user without sudo. EOF } @@ -120,6 +121,16 @@ if [[ -z "$EXECUTABLE" ]]; then 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 @@ -136,7 +147,8 @@ cat > "$OUTPUT_DIR/BUILD_INFO.json" <&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." From 90f05d0dcfb5f5b7f88318bd91f87ae1c939ddeb Mon Sep 17 00:00:00 2001 From: ign00 Date: Fri, 28 Aug 2026 18:12:23 -0400 Subject: [PATCH 39/39] ci: verify Linux launcher installation --- .github/workflows/linux-beta-build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/linux-beta-build.yml b/.github/workflows/linux-beta-build.yml index 0e8dc38..6bc09e7 100644 --- a/.github/workflows/linux-beta-build.yml +++ b/.github/workflows/linux-beta-build.yml @@ -4,6 +4,7 @@ on: push: branches: - feature/v1.2.0-linux-beta + - "release/**" pull_request: paths: - "**.py" @@ -44,6 +45,24 @@ jobs: - 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