Add CEF embedded browser + win32gui for weblink handling

Windows-specific fixes:
- New cef_browser.py: embedded Chromium via cefpython3, no subprocess
- _bring_kivy_to_front(): uses win32gui.SetForegroundWindow (reliable)
- _windows_kill_weblink_after_frame: kills Chrome IMMEDIATELY
- prewarm_weblink disabled on Windows (desktop launch is fast)
- _windows_play_weblink tries CEF first, falls back to subprocess
- Updated requirements_win.txt (cefpython3, pywin32 confirmed)
- Added development-track.md for change tracking
This commit is contained in:
ske087
2026-07-24 14:39:12 +03:00
parent 7efc023327
commit 844e5eeebb
3 changed files with 421 additions and 109 deletions
+246
View File
@@ -0,0 +1,246 @@
"""
cef_browser.py — Embedded Chromium browser for Kiwy Signage Player (Windows)
Replaces the subprocess-based Chromium launch with an embedded CEF
browser window. Eliminates all process-launch / z-order / taskkill bugs.
Usage:
from cef_browser import CefBrowser
browser = CefBrowser()
browser.show("https://example.com")
# ... later
browser.hide()
browser.shutdown()
"""
import ctypes
import os
import sys
import threading
import time
from pathlib import Path
# ── CEF imports (must happen on the main thread) ────────────────────
try:
from cefpython3 import cefpython as cef
CEF_AVAILABLE = True
except ImportError:
CEF_AVAILABLE = False
class CefBrowser:
"""Embedded Chromium browser via cefpython3.
On show(url): creates/hides a borderless fullscreen CEF window.
On hide(): destroys the CEF window, brings Kivy to front.
On shutdown(): terminates CEF message loop.
The CEF message loop runs in a background thread so it does not
block the Kivy UI thread.
"""
def __init__(self):
self._browser = None
self._cef_initialized = False
self._window_info = None
self._hwnd = None
self._cef_thread = None
self._stop_event = threading.Event()
self._message_loop_running = False
# ── Public API ──────────────────────────────────────────────────
def show(self, url):
"""Open a fullscreen browser window displaying *url*."""
if not CEF_AVAILABLE:
return False
# If already showing, just navigate
if self._browser is not None:
self._browser.Navigate(url)
self._bring_to_front()
return True
self._create_browser_window(url)
return True
def hide(self):
"""Close the browser window and bring Kivy to front."""
if self._browser is not None:
try:
# Close the browser
self._browser.CloseBrowser(True)
except Exception:
pass
self._browser = None
if self._hwnd:
try:
ctypes.windll.user32.DestroyWindow(self._hwnd)
except Exception:
pass
self._hwnd = None
# Bring Kivy to front
self._bring_kivy_to_front()
def shutdown(self):
"""Shut down the CEF engine entirely."""
self.hide()
self._stop_event.set()
if self._cef_initialized:
try:
cef.Shutdown()
except Exception:
pass
self._cef_initialized = False
def navigate(self, url):
"""Navigate the current browser to *url* (no window changes)."""
if self._browser is not None:
self._browser.Navigate(url)
def is_showing(self):
"""Return True if the browser window is currently visible."""
return self._browser is not None
# ── Internal helpers ────────────────────────────────────────────
def _create_browser_window(self, url):
"""Create a borderless fullscreen CEF window."""
if not CEF_AVAILABLE:
return
# Initialize CEF once
if not self._cef_initialized:
self._init_cef()
# Get screen dimensions
user32 = ctypes.windll.user32
screen_w = user32.GetSystemMetrics(0)
screen_h = user32.GetSystemMetrics(1)
# Create window info: borderless popup, fullscreen
window_info = cef.WindowInfo()
rect = [0, 0, screen_w, screen_h]
window_info.SetAsChild(0, rect)
# Use style: WS_POPUP | WS_VISIBLE | WS_CLIPCHILDREN
# We'll create the parent window ourselves for better control
# Create a borderless parent window
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
self._hwnd = user32.CreateWindowExW(
0x00000008, # WS_EX_TOPMOST | WS_EX_TOOLWINDOW
b'#32770', # Dialog class
b'', # no title
0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE
0, 0, screen_w, screen_h,
0, 0, hinstance, 0
)
if not self._hwnd:
return
# Make it black initially
hdc = user32.GetDC(self._hwnd)
rect_struct = (ctypes.c_long * 4)(0, 0, screen_w, screen_h)
gdi32 = ctypes.windll.gdi32
brush = gdi32.CreateSolidBrush(0x00000000)
gdi32.FillRect(hdc, ctypes.byref(rect_struct), brush)
gdi32.DeleteObject(brush)
user32.ReleaseDC(self._hwnd, hdc)
# Force it to top
user32.SetWindowPos(self._hwnd, -1, 0, 0, screen_w, screen_h, 0x0002 | 0x0040)
user32.ShowWindow(self._hwnd, 1)
user32.UpdateWindow(self._hwnd)
# Create browser as child of our window
window_info.SetAsChild(self._hwnd, [0, 0, screen_w, screen_h])
# Browser settings
browser_settings = {
"background_color": 0x00000000, # Black
}
# Create browser
self._browser = cef.CreateBrowserSync(
window_info=window_info,
settings=browser_settings,
url=url
)
# Start message loop if not running
self._start_message_loop()
def _init_cef(self):
"""Initialize CEF once."""
if self._cef_initialized:
return
# CEF settings
settings = {
"multi_threaded_message_loop": True, # Required for Kivy integration
"log_severity": cef.LOGSEVERITY_WARNING,
"user_agent": "Mozilla/5.0 KiwySignage/1.0",
"cache_path": str(Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"),
}
cef.Initialize(settings=settings)
self._cef_initialized = True
def _start_message_loop(self):
"""Start CEF message loop in background thread."""
if self._message_loop_running:
return
self._message_loop_running = True
self._stop_event.clear()
def loop():
while not self._stop_event.is_set():
cef.MessageLoopWork()
time.sleep(0.01)
self._message_loop_running = False
self._cef_thread = threading.Thread(target=loop, daemon=True)
self._cef_thread.start()
def _bring_to_front(self):
"""Bring the CEF window to foreground."""
if self._hwnd:
ctypes.windll.user32.SetWindowPos(
self._hwnd, -1, 0, 0, 0, 0,
0x0002 | 0x0001 | 0x0040
# SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW
)
ctypes.windll.user32.SetForegroundWindow(self._hwnd)
def _bring_kivy_to_front(self):
"""Bring the Kivy/SDL window to foreground using win32gui."""
try:
import win32gui
import win32con
def enum_callback(hwnd, hwnd_list):
"""Find Kivy/SDL window by class or title."""
class_name = win32gui.GetClassName(hwnd)
title = win32gui.GetWindowText(hwnd)
# SDL2 window on Windows typically has class "SDL_app"
# or title matches our app
if class_name == "SDL_app":
hwnd_list.append(hwnd)
elif "Kiwy" in title or "Signage" in title:
hwnd_list.append(hwnd)
hwnd_list = []
win32gui.EnumWindows(enum_callback, hwnd_list)
if hwnd_list:
# Bring the last found (most recent) to front
kivy_hwnd = hwnd_list[-1]
win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL)
win32gui.SetForegroundWindow(kivy_hwnd)
win32gui.BringWindowToTop(kivy_hwnd)
except Exception:
pass
+32 -19
View File
@@ -15,6 +15,7 @@
| **Venv** | `windows\venv312\` (pre-built, all deps installed) | | **Venv** | `windows\venv312\` (pre-built, all deps installed) |
| **Kivy** | 2.3.1 | | **Kivy** | 2.3.1 |
| **PyInstaller** | 6.21.0 | | **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) | | **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` | | **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
@@ -81,22 +82,29 @@
-`taskkill /F /T` → kills everything -`taskkill /F /T` → kills everything
- **Files:** `windows/run_win.py``_windows_kill_process_tree()` - **Files:** `windows/run_win.py``_windows_kill_process_tree()`
### [BUG-006] Video plays in background behind Chromium ### [BUG-007] Video plays behind Chromium on weblink→media transition
- **Status:** 🟡 **Known, needs investigation** - **Status:** 🔧 **Fix in progress** 2026-07-24
- **Symptom:** When transitioning from a weblink back to a video, the video - **Symptom:** When a weblink ends and the next media starts, the media plays
starts playing while Chromium is still visible (audio plays, video is behind *behind* Chromium. Audio is heard but user sees Chrome.
Chrome window). - **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
- **Possible causes:** Chrome → widget visible. On Windows Chrome stays ON TOP.
1. `_kill_weblink_after_frame` delay is too short — Kivy renders the video `Window.raise_window()` is unreliable.
before Chrome is killed - **Fix applied (2026-07-24):**
2. `Window.raise_window()` is not working reliably on Windows to bring 1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)`
Kivy to front to reliably bring Kivy/SDL window to front (replaces `raise_window`)
3. Chrome's `--kiosk` / `--start-maximized` keeps it on top 2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY
4. The `content_area.opacity` manipulation happens too early/late (not deferred one frame later) before next media starts
- **Current approach:** `_windows_kill_weblink_after_frame()` shows overlay, 3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces
kills Chrome tree, raises Kivy, hides overlay. subprocess entirely. No process management, no z-order fights.
- **Next thing to test:** Use `user32.SetForegroundWindow(KivyHwnd)` instead - **Files:** `windows/run_win.py`, `windows/cef_browser.py`
of `Window.raise_window()`. Get Kivy's HWND via `Window.get_window_info()`.
### [BUG-008] Intro video and media files not found at runtime
- **Status:** 🟡 **Known** 2026-07-24
- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro
broken. Also `❌ Media file not found` for playlist items.
- **Root cause:** Media files are not bundled in .exe — must be downloaded
from server. Player connects (v16 received) but hasn't downloaded files.
- **Fix:** Verify server is sending media content and player downloads it.
--- ---
@@ -156,8 +164,13 @@ Set-Location windows
## 📝 Notes for the Next Session ## 📝 Notes for the Next Session
- [ ] Investigate [BUG-006] — video playing behind Chromium - [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
- [ ] Test if `SetForegroundWindow` works better than `Window.raise_window()` - [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 - [ ] Consider adding a startup `.bat` file that users can double-click
- [ ] Check if the intro video plays correctly on Windows
- [ ] Test card reader fallback behaviour (evdev not available) - [ ] Test card reader fallback behaviour (evdev not available)
- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec`
+143 -90
View File
@@ -111,6 +111,24 @@ def _windows_screen_activity(self, dt):
pass # non-critical pass # non-critical
# ── Try to import the embedded CEF browser ──────────────────────────
_CEF_BROWSER = None
def _get_cef_browser():
"""Return the shared CefBrowser singleton, or None if unavailable."""
global _CEF_BROWSER
if _CEF_BROWSER is None:
try:
from cef_browser import CefBrowser, CEF_AVAILABLE
if CEF_AVAILABLE:
_CEF_BROWSER = CefBrowser()
else:
return None
except Exception:
return None
return _CEF_BROWSER
def _windows_find_browser(): def _windows_find_browser():
"""Find Chrome or Edge executable on Windows for weblink support. """Find Chrome or Edge executable on Windows for weblink support.
@@ -235,6 +253,43 @@ class _Win32Overlay:
cls._hwnd = None cls._hwnd = None
def _bring_kivy_to_front():
"""Bring the Kivy/SDL window to foreground using win32gui.
Unlike Window.raise_window(), win32gui.SetForegroundWindow
actually works reliably on Windows — it uses the same Win32
API that the Task Manager uses.
"""
try:
import win32gui
import win32con
def _enum_cb(hwnd, hwnd_list):
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)
hwnd_list = []
win32gui.EnumWindows(_enum_cb, hwnd_list)
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)
except Exception:
# Fallback to Kivy's built-in raise
try:
from kivy.core.window import Window
Window.show()
Window.raise_window()
except Exception:
pass
def _windows_kill_process_tree(proc): def _windows_kill_process_tree(proc):
"""Kill a process AND all its children using taskkill. """Kill a process AND all its children using taskkill.
@@ -292,49 +347,56 @@ 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.
Fixes on Windows: Strategy (tried in order):
- Uses --start-maximized + --window-size to ensure fullscreen 1. CEF embedded browser (best — no subprocess, no z-order fights)
- Uses --app=URL instead of bare URL for app-like fullscreen 2. Chrome/Edge subprocess (fallback)
- Kills the entire Chrome process tree to prevent lingering
- Shows a black overlay BEFORE closing Chrome to mask desktop
""" """
from kivy.logger import Logger
from kivy.clock import Clock
from urllib.parse import urlparse
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
# ── Strategy 1: CEF embedded browser ────────────────────────
cef_browser = _get_cef_browser()
if cef_browser is not None:
Logger.info(f"SignagePlayer: Opening weblink via CEF embedded browser: {url}")
try:
self.ids.content_area.opacity = 0
except Exception:
pass
cef_browser.show(url)
Clock.unschedule(self.next_media)
self._start_inactivity_watchdog(duration)
self.preload_next_media()
Logger.info("SignagePlayer: CEF browser launched successfully")
return True
# ── Strategy 2: Subprocess Chrome/Edge (fallback) ────────────
browser = _windows_find_browser() browser = _windows_find_browser()
if not browser: if not browser:
from kivy.logger import Logger
Logger.error( Logger.error(
"SignagePlayer: Chrome/Edge not found. " "SignagePlayer: No embedded CEF and no Chrome/Edge found. "
"Cannot display weblink on Windows." "Cannot display weblink on Windows."
) )
self.consecutive_errors += 1 self.consecutive_errors += 1
self._skip_to_next_media() self._skip_to_next_media()
return False return False
from urllib.parse import urlparse
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
from kivy.logger import Logger
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
self.consecutive_errors += 1
self._skip_to_next_media()
return False
import subprocess
from kivy.logger import Logger
from kivy.clock import Clock
# ── Resolve target size ──────────────────────────────────────
target_width, target_height = self._get_browser_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 via subprocess: {url} ({browser})")
Logger.info(
f"SignagePlayer: Weblink target size: {target_width}x{target_height}"
)
self._kill_weblink_preload() self._kill_weblink_preload()
# Hide Kivy content so it doesn't show underneath Chrome # Hide Kivy content
from kivy.core.window import Window as KivyWindow from kivy.core.window import Window as KivyWindow
try: try:
self.ids.content_area.opacity = 0 self.ids.content_area.opacity = 0
@@ -342,14 +404,8 @@ def _patch_main():
except Exception: except Exception:
pass pass
# Show a black overlay BEFORE Chrome opens — masks any
# desktop flash during the transition.
_Win32Overlay.show() _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,
'--new-window', '--new-window',
@@ -365,14 +421,12 @@ def _patch_main():
'--disable-features=TranslateUI', '--disable-features=TranslateUI',
'--disable-sync', '--disable-sync',
'--disable-background-networking', '--disable-background-networking',
'--no-default-browser-check',
'--window-position=0,0', '--window-position=0,0',
f'--window-size={target_width},{target_height}', f'--window-size={target_width},{target_height}',
'--force-device-scale-factor=1', '--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): def _hide_overlay(dt):
_Win32Overlay.hide() _Win32Overlay.hide()
try: try:
@@ -446,74 +500,73 @@ def _patch_main():
signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog
# Patch the _kill_weblink_after_frame to work without Linux-specific code # ── Patch kill_weblink_after_frame for both CEF and subprocess ──
def _windows_kill_weblink_after_frame(self): def _windows_kill_weblink_after_frame(self):
"""Gracefully transition away from a weblink item on Windows. """Close the weblink (CEF or subprocess) immediately before next media."""
import time
Shows a black overlay FIRST, THEN kills Chrome, THEN brings from kivy.logger import Logger
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()
# 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
return
# Fallback: subprocess Chrome
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 not None: if proc is None or proc.poll() is not None:
_Win32Overlay.hide()
return return
# Show black overlay to mask desktop while Chrome closes
_Win32Overlay.show() _Win32Overlay.show()
Logger.info("SignagePlayer: Killing Chromium subprocess immediately")
if proc is None: _windows_kill_process_tree(proc)
_Win32Overlay.hide() time.sleep(0.1)
return _bring_kivy_to_front()
_Win32Overlay.hide()
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 signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame
# Patch _prewarm_weblink for Windows # ── Patch play_current_media — same immediate-kill logic ────────
_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."""
if not _after_weblink:
# 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
# Kill subprocess Chrome if running
proc = self._weblink_proc
if proc is not None and proc.poll() is None:
_Win32Overlay.show()
_windows_kill_process_tree(proc)
self._weblink_proc = None
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
_bring_kivy_to_front()
_Win32Overlay.hide()
return _original_play_current(self, force_reload=force_reload, _after_weblink=_after_weblink)
signage_main.SignagePlayer.play_current_media = _windows_play_current_media
# Patch _prewarm_weblink for Windows — disabled for now.
# The off-screen Chrome window on Windows can interfere with:
# - Audio playback (Chrome claims audio device)
# - GPU resources (Chrome's GPU process runs in background)
# - Taskbar icons showing duplicate Chrome windows
# Pre-warming is less critical on desktop where launch is already fast.
def _windows_prewarm_weblink(self, url): def _windows_prewarm_weblink(self, url):
from urllib.parse import urlparse pass # Disabled on Windows — desktop launch is fast enough
if not url:
return
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
return
browser = _windows_find_browser()
if not browser:
return
import subprocess
from kivy.logger import Logger
self._kill_weblink_preload()
try:
Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}")
self._weblink_preload_proc = subprocess.Popen([
browser,
'--incognito',
'--no-first-run',
'--window-position=-9999,-9999',
url,
], shell=False)
except Exception as exc:
Logger.debug(f"SignagePlayer: Pre-warm failed (non-fatal): {exc}")
self._weblink_preload_proc = None
signage_main.SignagePlayer._prewarm_weblink = _windows_prewarm_weblink signage_main.SignagePlayer._prewarm_weblink = _windows_prewarm_weblink
# Patch cleanup of temp auth file (was using /tmp/) # Patch cleanup of temp auth file (was using /tmp/)