From 5c2b3f545f325ae58a2a33c7655a7c25781b0abf Mon Sep 17 00:00:00 2001 From: ske087 Date: Fri, 31 Jul 2026 15:40:59 +0300 Subject: [PATCH] working windows module --- src/network_monitor.py | 263 ++++++++++++++++++++++------------- windows/build.spec | 95 +++++++++---- windows/development-track.md | 153 +++++++++++++++----- windows/run_win.py | 226 ++++++++++++++++++++++++++---- 4 files changed, 556 insertions(+), 181 deletions(-) diff --git a/src/network_monitor.py b/src/network_monitor.py index 321919f..e685d12 100644 --- a/src/network_monitor.py +++ b/src/network_monitor.py @@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure import subprocess import time import random +import platform import requests from datetime import datetime from kivy.logger import Logger from kivy.clock import Clock +# Detect platform once so the ping / WiFi-restart commands below can +# pick the correct syntax (Linux vs Windows). +IS_WINDOWS = platform.system() == 'Windows' + class NetworkMonitor: """Monitor network connectivity and manage WiFi restart""" @@ -99,9 +104,15 @@ class NetworkMonitor: Logger.info(f"NetworkMonitor: Pinging server: {hostname}") - # Ping the server hostname with 3 attempts + # Ping the server hostname with 3 attempts. + # Windows ping uses -n for count and -w for timeout (ms), + # while Linux uses -c and -W. + if IS_WINDOWS: + cmd = ['ping', '-n', '3', '-w', '3000', hostname] + else: + cmd = ['ping', '-c', '3', '-W', '3', hostname] result = subprocess.run( - ['ping', '-c', '3', '-W', '3', hostname], + cmd, capture_output=True, text=True, timeout=10 @@ -123,113 +134,171 @@ class NetworkMonitor: def _restart_wifi(self): """ - Restart WiFi by turning it off for a specified duration then back on - This runs in a separate thread to not block the main application + Restart WiFi by turning it off for a specified duration then back on. + Uses the platform-appropriate commands: + - Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient + - Windows: netsh wlan disconnect / connect + This runs in a separate thread to not block the main application. """ def wifi_restart_thread(): try: Logger.info("NetworkMonitor: ====================================") Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE") Logger.info("NetworkMonitor: ====================================") - - # Turn off WiFi using rfkill (more reliable on Raspberry Pi) - Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...") - result = subprocess.run( - ['sudo', 'rfkill', 'block', 'wifi'], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)") - Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF") + + if IS_WINDOWS: + self._restart_wifi_windows() else: - Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...") - Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}") - - # Fallback to ifconfig - result2 = subprocess.run( - ['sudo', 'ifconfig', 'wlan0', 'down'], - capture_output=True, - text=True, - timeout=10 - ) - - if result2.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)") - else: - Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}") - Logger.error(f"NetworkMonitor: Return code: {result2.returncode}") - Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}") - return - - # Wait for the specified duration with WiFi OFF - wait_minutes = self.wifi_restart_duration / 60 - Logger.info(f"NetworkMonitor: ====================================") - Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes") - Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}") - Logger.info(f"NetworkMonitor: ====================================") - - # Sleep while WiFi is OFF - time.sleep(self.wifi_restart_duration) - - Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}") - - # Turn WiFi back on after the wait period - Logger.info("NetworkMonitor: ====================================") - Logger.info("NetworkMonitor: Now turning WiFi back ON...") - Logger.info("NetworkMonitor: ====================================") - - # Unblock WiFi using rfkill - result = subprocess.run( - ['sudo', 'rfkill', 'unblock', 'wifi'], - capture_output=True, - text=True, - timeout=10 - ) - - if result.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)") - else: - Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}") - - # Also bring interface up - result2 = subprocess.run( - ['sudo', 'ifconfig', 'wlan0', 'up'], - capture_output=True, - text=True, - timeout=10 - ) - - if result2.returncode == 0: - Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully") - - # Wait a bit for connection to establish - Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...") - time.sleep(10) - - # Try to restart DHCP - Logger.info("NetworkMonitor: Requesting IP address...") - subprocess.run( - ['sudo', 'dhclient', 'wlan0'], - capture_output=True, - text=True, - timeout=15 - ) - - Logger.info("NetworkMonitor: ====================================") - Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED") - Logger.info("NetworkMonitor: ====================================") - else: - Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}") - + self._restart_wifi_linux() + except subprocess.TimeoutExpired: Logger.error("NetworkMonitor: WiFi restart command timeout") except Exception as e: Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}") - + # Run in separate thread to not block the application import threading thread = threading.Thread(target=wifi_restart_thread, daemon=True) thread.start() + + def _restart_wifi_windows(self): + """Windows WiFi restart using netsh. Turn off for the wait period, + then turn back on so Windows reconnects to the preferred network.""" + wait_minutes = self.wifi_restart_duration / 60 + Logger.info( + f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min" + ) + + # Turn WiFi OFF + off = subprocess.run( + ['netsh', 'wlan', 'disconnect'], + capture_output=True, text=True, timeout=10 + ) + if off.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)") + else: + Logger.warning( + f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}" + ) + + # Wait with WiFi OFF + Logger.info( + f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes " + f"(started {datetime.now().strftime('%H:%M:%S')})" + ) + time.sleep(self.wifi_restart_duration) + Logger.info( + f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}" + ) + + # Turn WiFi back ON — Windows reconnects to the preferred network + on = subprocess.run( + ['netsh', 'wlan', 'connect'], + capture_output=True, text=True, timeout=10 + ) + if on.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)") + else: + # 'netsh wlan connect' without a profile may return non-zero even + # though the radio comes back on; log it but don't fail hard. + Logger.warning( + f"NetworkMonitor: netsh wlan connect returned {on.returncode}: " + f"{on.stderr.strip()} (may reconnect automatically)" + ) + + Logger.info("NetworkMonitor: ====================================") + Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED") + Logger.info("NetworkMonitor: ====================================") + + def _restart_wifi_linux(self): + """Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient.""" + # Turn off WiFi using rfkill (more reliable on Raspberry Pi) + Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...") + result = subprocess.run( + ['sudo', 'rfkill', 'block', 'wifi'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)") + else: + Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...") + Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}") + + # Fallback to ifconfig + result2 = subprocess.run( + ['sudo', 'ifconfig', 'wlan0', 'down'], + capture_output=True, + text=True, + timeout=10 + ) + + if result2.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)") + else: + Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}") + Logger.error(f"NetworkMonitor: Return code: {result2.returncode}") + Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}") + return + + # Wait for the specified duration with WiFi OFF + wait_minutes = self.wifi_restart_duration / 60 + Logger.info(f"NetworkMonitor: ====================================") + Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes") + Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}") + Logger.info(f"NetworkMonitor: ====================================") + + # Sleep while WiFi is OFF + time.sleep(self.wifi_restart_duration) + + Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}") + + # Turn WiFi back on after the wait period + Logger.info("NetworkMonitor: ====================================") + Logger.info("NetworkMonitor: Now turning WiFi back ON...") + Logger.info("NetworkMonitor: ====================================") + + # Unblock WiFi using rfkill + result = subprocess.run( + ['sudo', 'rfkill', 'unblock', 'wifi'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)") + else: + Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}") + + # Also bring interface up + result2 = subprocess.run( + ['sudo', 'ifconfig', 'wlan0', 'up'], + capture_output=True, + text=True, + timeout=10 + ) + + if result2.returncode == 0: + Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully") + + # Wait a bit for connection to establish + Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...") + time.sleep(10) + + # Try to restart DHCP + Logger.info("NetworkMonitor: Requesting IP address...") + subprocess.run( + ['sudo', 'dhclient', 'wlan0'], + capture_output=True, + text=True, + timeout=15 + ) + + Logger.info("NetworkMonitor: ====================================") + Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED") + Logger.info("NetworkMonitor: ====================================") + else: + Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}") diff --git a/windows/build.spec b/windows/build.spec index bd3b2bc..8f73310 100644 --- a/windows/build.spec +++ b/windows/build.spec @@ -153,37 +153,78 @@ source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '* import importlib.util from pathlib import Path as _Path -def _find_share_dlls(package_path, subdir='bin'): - """Find .dll files under a package's share/ directory.""" - if not package_path: + +def _site_packages_dir(package_path): + """Climb up from a package's __init__.py to its site-packages dir.""" + d = _Path(package_path).parent + while d.name != 'site-packages' and d.parent != d: + d = d.parent + return d + + +def _find_share_dlls(package_name, share_name=None): + """Find .dll files under venv_root/share//. + + kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into + /share//... NOT inside the package dir. The share folder is + named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the + dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly. + """ + if share_name is None: + share_name = package_name + spec = importlib.util.find_spec(package_name) + if spec is None or not spec.origin: return [] - base = _Path(package_path).parent - # Check: share//bin/ relative to parent - share = base / 'share' - if share.is_dir(): + sp = _site_packages_dir(spec.origin) + # Climb from site-packages up until we find a sibling 'share' dir + # (site-packages -> Lib -> venv, where venv/share lives). + d = sp + while d.parent != d: + if (d.parent / 'share').is_dir(): + share = d.parent / 'share' / share_name + break + d = d.parent + else: + return [] + if not share.is_dir(): + return [] + results = [] + for root, dirs, files in os.walk(share): + for f in files: + if f.endswith('.dll'): + results.append((os.path.join(root, f), '.')) + return results + + +def _find_ffpyplayer_bins(): + """Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL). + + ffpyplayer ships a `dep_bins` list that already points at the correct + share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories. + """ + try: + import ffpyplayer + bins = getattr(ffpyplayer, 'dep_bins', None) + if not bins: + return [] results = [] - for root, dirs, files in os.walk(share): - for f in files: - if f.endswith('.dll'): - results.append((os.path.join(root, f), '.')) + for b in bins: + bpath = _Path(b) + if bpath.is_dir(): + for f in bpath.glob('*.dll'): + results.append((str(f), '.')) return results - return [] + except Exception: + return [] -# SDL2 DLLs -_sdl2_spec = importlib.util.find_spec('kivy_deps.sdl2') -_sdl2_dlls = _find_share_dlls(_sdl2_spec.origin if _sdl2_spec else None) -# ANGLE DLLs -_angle_spec = importlib.util.find_spec('kivy_deps.angle') -_angle_dlls = _find_share_dlls(_angle_spec.origin if _angle_spec else None) +# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs) +_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2') +_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle') +_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew') -# GLEW DLLs -_glew_spec = importlib.util.find_spec('kivy_deps.glew') -_glew_dlls = _find_share_dlls(_glew_spec.origin if _glew_spec else None) - -# ffpyplayer FFmpeg DLLs -_ffpy_spec = importlib.util.find_spec('ffpyplayer') -_ffpy_dlls = _find_share_dlls(_ffpy_spec.origin if _ffpy_spec else None) +# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins) +_ffpy_dlls = _find_ffpyplayer_bins() _all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls @@ -194,6 +235,10 @@ if not _all_binaries: print("fails with 'SDL2.dll not found' or similar, you will need") print("to manually add the DLL paths to the spec file.") print("=" * 70) +else: + print(f"[spec] Bundling {len(_all_binaries)} DLLs:") + for _p, _t in sorted(_all_binaries): + print(f" {_Path(_p).name} <- {_p}") # --- Build the .exe -------------------------------------------------- a = Analysis( diff --git a/windows/development-track.md b/windows/development-track.md index 12cb0c9..06dceb6 100644 --- a/windows/development-track.md +++ b/windows/development-track.md @@ -6,29 +6,103 @@ --- -## 📅 Current Session — 2026-07-24 +## 📅 Current Session — 2026-07-31 | Field | Value | |-------|-------| | **Branch** | `Windows-Player` | -| **Python** | 3.12.9 — `C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe` | -| **Venv** | `windows\venv312\` (pre-built, all deps installed) | +| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) | | **Kivy** | 2.3.1 | | **PyInstaller** | 6.21.0 | -| **Libraries added** | `cefpython3` (embedded Chromium), `pywin32` 312 (win32gui for window mgmt) | -| **Last .exe build** | 2026-07-24 13:47 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) | -| **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` | +| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) | +| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` | -### ⚠️ Python Version Constraints -- **Python 3.12.9** — ✅ Confirmed working. Has pre-built Kivy 2.3.1 wheels. -- **Python 3.13** — ❌ Kivy wheels NOT available for Windows. -- **Python 3.14** — ❌ Tested 2026-07-24. `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel. - → Solution: removed Python 3.14 from system, keeping only 3.12.9. +### 📋 Cross-platform audit — Linux commands → Windows handling + +Every Linux-only command in `src/` was cross-referenced against the patches +in `windows/run_win.py`. All are covered except the one listed below: + +| # | File / method | Linux commands | Windows handling | +|---|---------------|----------------|------------------| +| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` | +| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess | +| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog | +| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back | +| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` | +| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` | +| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** | +| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** | +| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands | --- ## 🐛 Bug Tracker +### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands +- **Status:** ✅ **Fixed — 2026-07-31** +- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on + connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` / + `sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present). +- **Root cause:** This module was missed when the other Linux paths were + patched in `run_win.py`. +- **Fix:** Made `network_monitor.py` self-contained cross-platform: + 1. Added `IS_WINDOWS = platform.system() == 'Windows'` + 2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows + 3. `_restart_wifi()` dispatches to `_restart_wifi_windows()` + (`netsh wlan disconnect` → wait → `netsh wlan connect`) or + `_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact) +- **Files:** `src/network_monitor.py` +- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK. + +--- + +### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly) +- **Status:** ✅ **Fixed — 2026-07-31** +- **Symptom:** Web link items don't show. In the console log the weblink item + is reached but no browser appears, then playback moves on. +- **Root causes (two compounding):** + 1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])` + delegates the URL to the already-running Chrome process and this launched + process **exits immediately** (`poll() != None`) → the watchdog fired + instantly and advanced to the next item, so the weblink never displayed. + 2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called + `_bring_kivy_to_front()`, so even when Chrome did open it sat *behind* + the borderless-fullscreen Kivy window. +- **Fix (in `windows/run_win.py`):** + 1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`/.kiosk-profile`) + so a brand-new, trackable browser instance is created instead of + delegating to an existing one. Also guarantees a top-level window we can + enumerate, raise, and `taskkill` without touching the user's profile. + 2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper + that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID) + instead of raising Kivy. +- **Test:** exe rebuilt 2026-07-31 13:53; DLL set intact (28 DLLs incl. FFmpeg). + +### [BUG-012] Next widget never comes to foreground after weblink ends +- **Status:** ✅ **Fixed — 2026-07-31** +- **Symptom:** After a weblink finishes, the next media/widget renders but the + Kivy window stays behind (or the window focus is lost) — user sees the wrong + window / frozen view. +- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but + `win32con` is a pure-Python module in `win32\lib\` that is **only importable + via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller + apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` — + only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The + `import win32con` threw, the whole function silently fell back to + `Window.raise_window()`, and the Kivy window was never reliably raised. +- **Fix (in `windows/run_win.py`):** + 1. Replaced the `win32con` dependency with **raw ctypes + numeric constants** + (`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …). + 2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with + `AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost + flash. + 3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows + for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as + last-resort fallback. +- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`. + +--- + ### [BUG-001] RecursionError: play_current_media ↔ restart_playlist - **Status:** ✅ Fixed 2026-07-24 - **Symptom:** Pressing "Restart Player" in settings with empty playlist causes @@ -83,20 +157,31 @@ - **Files:** `windows/run_win.py` — `_windows_kill_process_tree()` ### [BUG-007] Video plays behind Chromium on weblink→media transition -- **Status:** 🔧 **Fix in progress** 2026-07-24 +- **Status:** ✅ **Fixed — 2026-07-26 (final)** - **Symptom:** When a weblink ends and the next media starts, the media plays *behind* Chromium. Audio is heard but user sees Chrome. - **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes Chrome → widget visible. On Windows Chrome stays ON TOP. - `Window.raise_window()` is unreliable. -- **Fix applied (2026-07-24):** - 1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)` - to reliably bring Kivy/SDL window to front (replaces `raise_window`) - 2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY - (not deferred one frame later) before next media starts - 3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces - subprocess entirely. No process management, no z-order fights. -- **Files:** `windows/run_win.py`, `windows/cef_browser.py` + `Window.raise_window()` is unreliable. Three compounding issues: + 1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably + 2. `_windows_play_current_media` killed the browser but never restored + `content_area.opacity = 1`, so next widget rendered invisible + 3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow` + refuses to let a background process steal focus +- **Fix applied (2026-07-26):** + 1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy + stays visible behind the overlay instead of being hidden + 2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media` + and `_windows_kill_weblink_after_frame()` — ensures next widget is visible + 3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass + Windows foreground lock so Kivy can steal focus from Chrome + 4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of + `Window.raise_window()` + 5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls + `_bring_kivy_to_front()` after hiding +- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess + Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path. +- **Files:** `windows/run_win.py` ### [BUG-008] Intro video and media files not found at runtime - **Status:** ✅ **Fixed** 2026-07-24 @@ -159,15 +244,15 @@ When the .exe runs: ## 🔧 Build Cheatsheet ```powershell -# Build the .exe (from project root or windows/) +# Build the .exe (from windows/ directory) Set-Location windows -& .\venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm +& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm # Run in dev mode (no build needed) -& .\venv312\Scripts\python.exe run_win.py +& .\venv\Scripts\python.exe run_win.py # Test imports only -& .\venv312\Scripts\python.exe test_import_fix.py +& .\venv\Scripts\python.exe test_import_fix.py ``` --- @@ -177,10 +262,14 @@ Set-Location windows - [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui - [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui` - [x] Install `cefpython3` — embedded Chromium, no more subprocess -- [ ] Verify CEF embedded browser actually works at runtime -- [ ] Test the subprocess fallback path when CEF is unavailable -- [ ] Check why `AsyncImage` error shows for intro1.mp4 (path issue) -- [ ] Ensure media files are downloaded before playback -- [ ] Consider adding a startup `.bat` file that users can double-click -- [ ] Test card reader fallback behaviour (evdev not available) -- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec` +- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge +- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()` +- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir +- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run +- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec` +- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux) +- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix +- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway) +- [ ] Verify the .exe works on a fresh Windows machine (no Python installed) +- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed +- [ ] Add a standalone `.bat` launcher for development mode diff --git a/windows/run_win.py b/windows/run_win.py index cd41966..7852881 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -258,33 +258,101 @@ class _Win32Overlay: cls._hwnd = None -def _bring_kivy_to_front(): - """Bring the Kivy/SDL window to foreground using win32gui. +# Win32 constants used directly (avoid `import win32con` — win32con is a +# pure-Python module in win32\\lib\\ that PyInstaller does NOT bundle because +# it is only reachable through the pywin32.pth file, which frozen apps ignore). +_SW_SHOWNORMAL = 1 +_SW_MINIMIZE = 6 +_SW_RESTORE = 9 +_SWP_NOSIZE = 0x0001 +_SWP_NOMOVE = 0x0002 +_SWP_NOACTIVATE = 0x0010 +_SWP_SHOWWINDOW = 0x0040 +_HWND_TOPMOST = -1 +_HWND_NOTOPMOST = -2 +_GWL_EXSTYLE = -20 +_WS_EX_TOPMOST = 0x00000008 - Unlike Window.raise_window(), win32gui.SetForegroundWindow - actually works reliably on Windows — it uses the same Win32 - API that the Task Manager uses. + +def _bring_hwnd_to_front(hwnd): + """Force a Win32 window to the foreground using only ctypes. + + IMPORTANT: Windows restricts SetForegroundWindow() — a process can only + set the foreground window if it was the *last input process* or the + current foreground window is the same thread. To work around this, we + attach our calling thread (and the target window's thread) to the current + foreground window's input thread before calling SetForegroundWindow. """ + if not hwnd: + return + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + + # If minimized, restore first so the window can actually be shown. + if user32.IsIconic(hwnd): + user32.ShowWindow(hwnd, _SW_RESTORE) + + try: + fore_hwnd = user32.GetForegroundWindow() + if fore_hwnd and fore_hwnd != hwnd: + fore_tid = user32.GetWindowThreadProcessId(fore_hwnd, None) + target_tid = user32.GetWindowThreadProcessId(hwnd, None) + our_tid = kernel32.GetCurrentThreadId() + if fore_tid != our_tid: + user32.AttachThreadInput(our_tid, fore_tid, True) + user32.AttachThreadInput(target_tid, fore_tid, True) + user32.SetForegroundWindow(hwnd) + user32.AttachThreadInput(target_tid, fore_tid, False) + user32.AttachThreadInput(our_tid, fore_tid, False) + else: + user32.SetForegroundWindow(hwnd) + else: + user32.SetForegroundWindow(hwnd) + except Exception: + pass + + user32.ShowWindow(hwnd, _SW_SHOWNORMAL) + user32.BringWindowToTop(hwnd) + user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + + +def _find_kivy_hwnd(): + """Return the HWND of the Kivy/SDL window, or None.""" try: import win32gui - import win32con + except Exception: + return None + hwnd_list = [] - def _enum_cb(hwnd, hwnd_list): + def _enum_cb(hwnd, _): + try: cls = win32gui.GetClassName(hwnd) title = win32gui.GetWindowText(hwnd) - if cls == "SDL_app": - hwnd_list.append(hwnd) - elif "Kiwy" in title or "Signage" in title: - hwnd_list.append(hwnd) + except Exception: + return + if cls == "SDL_app" or "Kiwy" in title or "Signage" in title: + hwnd_list.append(hwnd) - hwnd_list = [] - win32gui.EnumWindows(_enum_cb, hwnd_list) + try: + win32gui.EnumWindows(_enum_cb, None) + except Exception: + pass + return hwnd_list[-1] if hwnd_list else None - if hwnd_list: - kivy_hwnd = hwnd_list[-1] # most recent - win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL) - win32gui.SetForegroundWindow(kivy_hwnd) - win32gui.BringWindowToTop(kivy_hwnd) + +def _bring_kivy_to_front(): + """Bring the Kivy/SDL window to the foreground. + + Uses win32gui.EnumWindows to find the SDL_app window, then _bring_hwnd_to_front + (ctypes-only) to force it forward — no dependency on the un-bundled + `win32con` module. Falls back to Kivy's built-in raise_window(). + """ + try: + hwnd = _find_kivy_hwnd() + if hwnd is None: + return + _bring_hwnd_to_front(hwnd) except Exception: # Fallback to Kivy's built-in raise try: @@ -295,6 +363,58 @@ def _bring_kivy_to_front(): pass +def _bring_chrome_to_front(proc): + """Find the top-level window of a launched Chrome/Edge process and bring + it to the foreground (so the weblink is actually visible over Kivy).""" + if proc is None: + return + try: + import win32gui + import win32process + except Exception: + return + + target_pid = proc.pid + chrome_hwnd = None + + def _enum_cb(hwnd, _): + nonlocal chrome_hwnd + if chrome_hwnd is not None: + return + try: + _, pid = win32process.GetWindowThreadProcessId(hwnd) + except Exception: + return + if pid != target_pid: + return + try: + cls = win32gui.GetClassName(hwnd) + except Exception: + return + # Chrome's top-level window is class 'Chrome_WidgetWin_1' (or 0) + if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'): + if win32gui.IsWindowVisible(hwnd): + chrome_hwnd = hwnd + + try: + win32gui.EnumWindows(_enum_cb, None) + except Exception: + pass + + if chrome_hwnd is not None: + _bring_hwnd_to_front(chrome_hwnd) + else: + # Give the browser a moment to create its window, then retry once. + import time + time.sleep(0.3) + try: + win32gui.EnumWindows(_enum_cb, None) + except Exception: + pass + if chrome_hwnd is not None: + _bring_hwnd_to_front(chrome_hwnd) + + def _windows_kill_process_tree(proc): """Kill a process AND all its children using taskkill. @@ -422,18 +542,36 @@ def _patch_main(): Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})") self._kill_weblink_preload() - # Hide Kivy content + # Hide Kivy content (do NOT minimize — that makes it impossible + # to reliably bring Kivy back to foreground after Chrome closes). from kivy.core.window import Window as KivyWindow try: self.ids.content_area.opacity = 0 - KivyWindow.minimize() except Exception: pass _Win32Overlay.show() + # CRITICAL: use a dedicated --user-data-dir. Without it, Chrome + # hands the URL to the existing browser process and this launched + # process exits immediately (poll() != None), so the watchdog + # advances instantly and the weblink never displays. A private + # profile also guarantees a brand-new top-level window we can + # track, bring to front, and taskkill without touching the user's + # normal browser session. + profile_dir = os.path.join( + os.environ.get('KIWY_DATA_DIR', os.getcwd()), + '.kiosk-profile' + ) + try: + os.makedirs(profile_dir, exist_ok=True) + except Exception: + pass + self._weblink_proc = subprocess.Popen([ browser, + '--user-data-dir=' + profile_dir, + '--kiosk', '--new-window', '--start-maximized', '--start-fullscreen', @@ -453,13 +591,15 @@ def _patch_main(): url, ], shell=False) + # Hide the black overlay, then bring CHROME to the front — NOT + # Kivy. Kivy is a borderless fullscreen window; if we raise Kivy + # here the weblink would open *behind* it and never be visible. + weblink_proc = self._weblink_proc + def _hide_overlay(dt): _Win32Overlay.hide() - try: - KivyWindow.raise_window() - except Exception: - pass - Clock.schedule_once(_hide_overlay, 1.5) + _bring_chrome_to_front(weblink_proc) + Clock.schedule_once(_hide_overlay, 1.0) Clock.unschedule(self.next_media) self._start_inactivity_watchdog(duration) @@ -528,19 +668,31 @@ def _patch_main(): # ── Patch kill_weblink_after_frame for both CEF and subprocess ── def _windows_kill_weblink_after_frame(self): - """Close the weblink (CEF or subprocess) immediately before next media.""" + """Close the weblink (CEF or subprocess) immediately before next media. + + Restores Kivy content visibility and brings the Kivy window to front + in all cases. + """ import time from kivy.logger import Logger from kivy.clock import Clock + from kivy.core.window import Window as _KivyWindow self._stop_inactivity_watchdog() self._kill_weblink_preload() + # Restore Kivy content visibility + try: + self.ids.content_area.opacity = 1 + except Exception: + pass + # Try CEF first cef_browser = _get_cef_browser() if cef_browser is not None and cef_browser.is_showing(): Logger.info("SignagePlayer: Hiding CEF embedded browser") cef_browser.hide() self._weblink_proc = None + _bring_kivy_to_front() return # Fallback: subprocess Chrome @@ -548,6 +700,7 @@ def _patch_main(): self._weblink_proc = None if proc is None or proc.poll() is not None: + _bring_kivy_to_front() return _Win32Overlay.show() @@ -562,13 +715,32 @@ def _patch_main(): _original_play_current = signage_main.SignagePlayer.play_current_media def _windows_play_current_media(self, force_reload=False, _after_weblink=False): - """Wrapped play_current_media — closes weblink immediately on transition.""" + """Wrapped play_current_media — closes weblink immediately on transition. + + CRITICAL: Must restore content_area.opacity=1 BEFORE killing the browser, + because the original play_current_media() skips the weblink→media transition + block once self._weblink_proc is None. If opacity stays 0, the next widget + renders but is invisible. + """ if not _after_weblink: + # Restore Kivy content visibility BEFORE killing the browser so the + # original play_current_media() doesn't need to handle the transition. + try: + self.ids.content_area.opacity = 1 + except Exception: + pass + try: + from kivy.core.window import Window as _KivyWindow + _KivyWindow.show() + except Exception: + pass + # Kill CEF browser if showing cef_browser = _get_cef_browser() if cef_browser is not None and cef_browser.is_showing(): cef_browser.hide() self._weblink_proc = None + _bring_kivy_to_front() # Kill subprocess Chrome if running proc = self._weblink_proc