From 6abde5a7674e3d058888cee13e50f2f7e2f07f05 Mon Sep 17 00:00:00 2001 From: ske087 Date: Fri, 24 Jul 2026 13:48:49 +0300 Subject: [PATCH] Fix Windows weblink handling: fullscreen Chrome, black overlay masking, proper process tree kill Windows-specific fixes: - _windows_play_weblink: uses --start-maximized + --app=URL for true fullscreen - Shows black Win32 overlay before opening/closing Chrome to mask desktop - _windows_kill_process_tree: uses taskkill /F /T to kill all Chrome child processes - _Win32Overlay class: fullscreen borderless always-on-top black window - Updated README to note Python 3.12 requirement and local data dir behavior --- windows/README_WINDOWS_BUILD.md | 18 ++- windows/run_win.py | 220 +++++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 20 deletions(-) diff --git a/windows/README_WINDOWS_BUILD.md b/windows/README_WINDOWS_BUILD.md index 7f89203..298373f 100644 --- a/windows/README_WINDOWS_BUILD.md +++ b/windows/README_WINDOWS_BUILD.md @@ -36,7 +36,10 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie ## 🚀 Quick Start (Development) ### Prerequisites -1. **Python 3.10+** (64-bit) — [python.org](https://python.org) +1. **Python 3.12+** (64-bit) — [python.org](https://python.org) + - ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it + - ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available + - ✅ **Python 3.12.9** is the recommended version (confirmed working) 2. **FFmpeg** — for video codec support - Download from [ffmpeg.org](https://ffmpeg.org/download.html) - Add `bin\` folder to your PATH @@ -46,8 +49,11 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie ```batch cd windows -REM Create virtual environment -python -m venv venv +REM Create virtual environment with Python 3.12 +py -3.12 -m venv venv +:: OR specify full path: +:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv + venv\Scripts\activate REM Install dependencies @@ -86,8 +92,10 @@ For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` se ## ⚙️ Configuration -1. On first run, config files are created in `%APPDATA%\KiwySignage\` -2. Edit `%APPDATA%\KiwySignage\config\app_config.json` to set your server: +1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`) + - The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally + - This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works +2. Edit `config\app_config.json` (next to the .exe) to set your server: ```json { diff --git a/windows/run_win.py b/windows/run_win.py index ad054b2..4b2a342 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -139,6 +139,128 @@ def _windows_find_browser(): return None +# ── Win32 API helpers via ctypes ───────────────────────────────────── +class _Win32Overlay: + """Fullscreen black overlay window to mask desktop during transitions. + + When switching away from Chromium, the browser window disappears and + there is a brief moment where the desktop is visible before Kivy + manages to bring its window to the front. This overlay covers that + flash with a pure-black borderless always-on-top Win32 window. + """ + + _hwnd = None + _class_atom = None + + @classmethod + def show(cls): + """Create a fullscreen black overlay on top of everything.""" + if cls._hwnd is not None: + return # already showing + try: + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + + # Register a simple window class + WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_int64, ctypes.c_int64, ctypes.c_uint, + ctypes.c_uint64, ctypes.c_int64 + ) + + @WNDPROC + def wnd_proc(hwnd, msg, wparam, lparam): + if msg == 0x0002: # WM_DESTROY + user32.PostQuitMessage(0) + if msg == 0x0014: # WM_ERASEBKGND + return 1 # tell Windows we erased it + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + hinstance = kernel32.GetModuleHandleW(None) + + # Register class + class_name = 'KiwyOverlay_' + str(ctypes.c_uint64(int(kernel32.GetTickCount64())).value) + wc = ctypes.create_unicode_buffer(256) + + _WNDCLASS = ctypes.c_byte * (6 * 8) # rough size + buf = _WNDCLASS() + # Simple approach: use RegisterClassExW + user32.RegisterClassExW.restype = ctypes.c_uint16 + user32.RegisterClassExW.argtypes = [ctypes.c_void_p] + + # We'll use a simpler method: just create a MessageBox-style window + # Actually, let's use the simplest possible approach: + + # Get screen dimensions + screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN + screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN + + # Create a borderless always-on-top window + cls._hwnd = user32.CreateWindowExW( + 0x00000008, # WS_EX_TOPMOST | WS_EX_TOOLWINDOW + b'#32770', # Dialog class - always available + b'', # no title + 0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE + 0, 0, screen_w, screen_h, + 0, 0, hinstance, 0 + ) + + if cls._hwnd: + # Make it black + from ctypes import wintypes + gdi32 = ctypes.windll.gdi32 + hdc = user32.GetDC(cls._hwnd) + rect = (ctypes.c_long * 4)(0, 0, screen_w, screen_h) + brush = gdi32.CreateSolidBrush(0x00000000) # black brush + gdi32.FillRect(hdc, ctypes.byref(rect), brush) + gdi32.DeleteObject(brush) + user32.ReleaseDC(cls._hwnd, hdc) + + # Force it to the top + user32.SetWindowPos(cls._hwnd, -1, 0, 0, screen_w, screen_h, 0x0002 | 0x0040) + user32.ShowWindow(cls._hwnd, 1) # SW_SHOWNORMAL + user32.UpdateWindow(cls._hwnd) + except Exception: + cls._hwnd = None # failed gracefully + + @classmethod + def hide(cls): + """Destroy the overlay window.""" + if cls._hwnd is None: + return + try: + user32 = ctypes.windll.user32 + user32.DestroyWindow(cls._hwnd) + except Exception: + pass + cls._hwnd = None + + +def _windows_kill_process_tree(proc): + """Kill a process AND all its children using taskkill. + + Chrome/Edge spawns many child processes (GPU, renderer, network, + etc.). A simple proc.terminate() leaves children running, causing + lingering browser windows or zombie processes. + """ + if proc is None or proc.poll() is not None: + return + try: + subprocess.run( + ['taskkill', '/F', '/T', '/PID', str(proc.pid)], + capture_output=True, timeout=5 + ) + except Exception: + # Fallback: try terminate + kill + try: + proc.terminate() + try: + proc.wait(timeout=3) + except Exception: + proc.kill() + except Exception: + pass + + def _patch_main(): """Patch the main module after import for Windows compatibility.""" # ── CRITICAL: Override Linux env vars BEFORE importing main ───── @@ -170,7 +292,14 @@ def _patch_main(): _original_play_weblink = signage_main.SignagePlayer.play_weblink def _windows_play_weblink(self, url, duration): - """Windows-compatible weblink handler using Chrome/Edge.""" + """Windows-compatible weblink handler using Chrome/Edge. + + Fixes on Windows: + - Uses --start-maximized + --window-size to ensure fullscreen + - Uses --app=URL instead of bare URL for app-like fullscreen + - Kills the entire Chrome process tree to prevent lingering + - Shows a black overlay BEFORE closing Chrome to mask desktop + """ browser = _windows_find_browser() if not browser: from kivy.logger import Logger @@ -195,14 +324,38 @@ def _patch_main(): from kivy.logger import Logger from kivy.clock import Clock + # ── Resolve target size ────────────────────────────────────── + target_width, target_height = self._get_browser_target_size() + try: Logger.info(f"SignagePlayer: Opening weblink: {url} (browser: {browser})") + Logger.info( + f"SignagePlayer: Weblink target size: {target_width}x{target_height}" + ) self._kill_weblink_preload() - # Launch in fullscreen / kiosk-like mode + # Hide Kivy content so it doesn't show underneath Chrome + from kivy.core.window import Window as KivyWindow + try: + self.ids.content_area.opacity = 0 + KivyWindow.minimize() + except Exception: + pass + + # Show a black overlay BEFORE Chrome opens — masks any + # desktop flash during the transition. + _Win32Overlay.show() + + # Launch in fullscreen / app mode on Windows + # --start-maximized ensures it fills the screen on first paint + # --app=URL gives a window without address bar + # --window-size ensures the browser targets the correct resolution self._weblink_proc = subprocess.Popen([ browser, - '--kiosk', + '--new-window', + '--start-maximized', + '--start-fullscreen', + '--app=' + url, '--no-first-run', '--noerrdialogs', '--disable-infobars', @@ -210,9 +363,24 @@ def _patch_main(): '--no-default-browser-check', '--disable-session-crashed-bubble', '--disable-features=TranslateUI', + '--disable-sync', + '--disable-background-networking', + '--no-default-browser-check', + '--window-position=0,0', + f'--window-size={target_width},{target_height}', + '--force-device-scale-factor=1', url, ], shell=False) + # Give Chrome a moment to cover the screen, then hide overlay + def _hide_overlay(dt): + _Win32Overlay.hide() + try: + KivyWindow.raise_window() + except Exception: + pass + Clock.schedule_once(_hide_overlay, 1.5) + Clock.unschedule(self.next_media) self._start_inactivity_watchdog(duration) self.preload_next_media() @@ -220,6 +388,7 @@ def _patch_main(): except Exception as e: Logger.error(f"SignagePlayer: Error opening weblink: {e}") + _Win32Overlay.hide() self.consecutive_errors += 1 self._skip_to_next_media() return False @@ -279,23 +448,44 @@ def _patch_main(): # Patch the _kill_weblink_after_frame to work without Linux-specific code def _windows_kill_weblink_after_frame(self): + """Gracefully transition away from a weblink item on Windows. + + Shows a black overlay FIRST, THEN kills Chrome, THEN brings + Kivy to the front. This masks the desktop flash that happens + between Chrome closing and Kivy reappearing. + """ from kivy.clock import Clock self._stop_inactivity_watchdog() self._kill_weblink_preload() proc = self._weblink_proc self._weblink_proc = None - if proc is not None and proc.poll() is None: - def _do_kill(dt): - if proc.poll() is None: - try: - proc.terminate() - try: - proc.wait(timeout=3) - except Exception: - proc.kill() - except Exception: - pass - Clock.schedule_once(_do_kill, 0) + + if proc is not None and proc.poll() is not None: + _Win32Overlay.hide() + return + + # Show black overlay to mask desktop while Chrome closes + _Win32Overlay.show() + + if proc is None: + _Win32Overlay.hide() + return + + def _do_kill(dt): + if proc.poll() is None: + _windows_kill_process_tree(proc) + Logger.debug("SignagePlayer: Closed weblink browser (deferred)") + # Bring Kivy window to front + try: + from kivy.core.window import Window as KivyWindow + KivyWindow.show() + KivyWindow.raise_window() + except Exception: + pass + # Hide overlay — Kivy should be visible now + _Win32Overlay.hide() + + Clock.schedule_once(_do_kill, 0) signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame # Patch _prewarm_weblink for Windows