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
This commit is contained in:
ske087
2026-07-24 13:48:49 +03:00
parent 362f5096a0
commit 6abde5a767
2 changed files with 218 additions and 20 deletions
+13 -5
View File
@@ -36,7 +36,10 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
## 🚀 Quick Start (Development) ## 🚀 Quick Start (Development)
### Prerequisites ### 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 2. **FFmpeg** — for video codec support
- Download from [ffmpeg.org](https://ffmpeg.org/download.html) - Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- Add `bin\` folder to your PATH - Add `bin\` folder to your PATH
@@ -46,8 +49,11 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
```batch ```batch
cd windows cd windows
REM Create virtual environment REM Create virtual environment with Python 3.12
python -m venv venv 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 venv\Scripts\activate
REM Install dependencies REM Install dependencies
@@ -86,8 +92,10 @@ For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` se
## ⚙️ Configuration ## ⚙️ Configuration
1. On first run, config files are created in `%APPDATA%\KiwySignage\` 1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`)
2. Edit `%APPDATA%\KiwySignage\config\app_config.json` to set your server: - 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 ```json
{ {
+199 -9
View File
@@ -139,6 +139,128 @@ def _windows_find_browser():
return None 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(): def _patch_main():
"""Patch the main module after import for Windows compatibility.""" """Patch the main module after import for Windows compatibility."""
# ── CRITICAL: Override Linux env vars BEFORE importing main ───── # ── CRITICAL: Override Linux env vars BEFORE importing main ─────
@@ -170,7 +292,14 @@ def _patch_main():
_original_play_weblink = signage_main.SignagePlayer.play_weblink _original_play_weblink = signage_main.SignagePlayer.play_weblink
def _windows_play_weblink(self, url, duration): 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() browser = _windows_find_browser()
if not browser: if not browser:
from kivy.logger import Logger from kivy.logger import Logger
@@ -195,14 +324,38 @@ def _patch_main():
from kivy.logger import Logger from kivy.logger import Logger
from kivy.clock import Clock from kivy.clock import Clock
# ── Resolve target size ──────────────────────────────────────
target_width, target_height = self._get_browser_target_size()
try: try:
Logger.info(f"SignagePlayer: Opening weblink: {url} (browser: {browser})") 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() 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([ self._weblink_proc = subprocess.Popen([
browser, browser,
'--kiosk', '--new-window',
'--start-maximized',
'--start-fullscreen',
'--app=' + url,
'--no-first-run', '--no-first-run',
'--noerrdialogs', '--noerrdialogs',
'--disable-infobars', '--disable-infobars',
@@ -210,9 +363,24 @@ def _patch_main():
'--no-default-browser-check', '--no-default-browser-check',
'--disable-session-crashed-bubble', '--disable-session-crashed-bubble',
'--disable-features=TranslateUI', '--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, url,
], shell=False) ], 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) Clock.unschedule(self.next_media)
self._start_inactivity_watchdog(duration) self._start_inactivity_watchdog(duration)
self.preload_next_media() self.preload_next_media()
@@ -220,6 +388,7 @@ def _patch_main():
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error opening weblink: {e}") Logger.error(f"SignagePlayer: Error opening weblink: {e}")
_Win32Overlay.hide()
self.consecutive_errors += 1 self.consecutive_errors += 1
self._skip_to_next_media() self._skip_to_next_media()
return False return False
@@ -279,22 +448,43 @@ def _patch_main():
# Patch the _kill_weblink_after_frame to work without Linux-specific code # Patch the _kill_weblink_after_frame to work without Linux-specific code
def _windows_kill_weblink_after_frame(self): 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 from kivy.clock import Clock
self._stop_inactivity_watchdog() self._stop_inactivity_watchdog()
self._kill_weblink_preload() self._kill_weblink_preload()
proc = self._weblink_proc proc = self._weblink_proc
self._weblink_proc = None self._weblink_proc = None
if proc is not None and proc.poll() is None:
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): def _do_kill(dt):
if proc.poll() is None: if proc.poll() is None:
_windows_kill_process_tree(proc)
Logger.debug("SignagePlayer: Closed weblink browser (deferred)")
# Bring Kivy window to front
try: try:
proc.terminate() from kivy.core.window import Window as KivyWindow
try: KivyWindow.show()
proc.wait(timeout=3) KivyWindow.raise_window()
except Exception:
proc.kill()
except Exception: except Exception:
pass pass
# Hide overlay — Kivy should be visible now
_Win32Overlay.hide()
Clock.schedule_once(_do_kill, 0) Clock.schedule_once(_do_kill, 0)
signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame