d8c6ab0bc5
Two independent failures were killing long unattended runs.
1. HANG at the end of a video (Windows AppHangB1)
The player froze after ~30-45 minutes of looping, always at a video item. The
playback trace stopped dead right after "video_loaded" with no "video_eos" and
no "advance_after_video_eos", and Windows logged AppHangB1 rather than a crash.
Cause, all inside Kivy and verified against the installed source:
1. ffpyplayer fires on_eos.
2. Kivy's Video widget binds its OWN handler first (kivy/uix/video.py
_do_video_load), and that handler sets state = 'stop' DURING the event
dispatch.
3. state = 'stop' -> VideoFFPy.stop() -> unload(), which calls
self._thread.join() with no timeout (the source even carries the comment
"TODO: use callback, don't block here").
4. When that decode thread is slow to exit, the Kivy/SDL main thread never
returns, so the window stops pumping messages.
It is a race, which is why it looked random and only appeared after many videos.
src/video_safety.py bounds that join. ffpyplayer has already been told to quit
and its thread woken before the join, so limiting the wait does not leak work;
it only stops an unresponsive thread from taking the whole player down. The
guard is installed before the Video widget is constructed, because the decode
thread is created during play().
The intro video had the same hazard on the main thread (state='stop' followed by
unload() inside the state callback) and is now torn down on a worker thread like
playlist videos.
2. CRASH in SDL2_mixer.dll (0xc0000005) on a video with no audio stream
Triggered when a silent 4K clip entered the playlist while the item was marked
audio: on. ffpyplayer initialises SDL2_mixer from the FIRST audio file it opens
and reuses those parameters, so a file with no audio stream (rate/channels 0)
makes SDL2_mixer dereference garbage. Muting via volume=0.0 does NOT avoid it -
the audio stream itself must be disabled.
play_video now probes the file with ffprobe and forces mute when it has no audio
track, so such a file can never reach ffpyplayer with sound enabled. The probe
fails safe (assumes audio present) if ffprobe is unavailable.
3. 24/7 supervision (solution A + C)
windows/watchdog.ps1 + start_player_watchdog.bat restart the player when it
crashes (process gone) or hangs (process alive but .player_heartbeat stale),
with a crash-loop breaker that backs off when it cannot stay up. This is the
Windows counterpart of the proven Linux start.sh watchdog.
The exit-screen password remains the only supported way to stop the player. On
success it writes .player_stop_requested next to the .exe and the watchdog
stands down instead of restarting. The flag is SESSION SCOPED: the watchdog
clears it on every start, so launching again begins a new session and there is
no file to delete by hand. Clearing on start also means a power cut cannot leave
the player permanently off.
Deliberately NOT done: a Windows service. A service runs in session 0 with no
desktop, so the player could not render to the screen at all. A login-triggered
startup entry is the correct Windows analogue of the Pi's systemd unit.
Important detail: the packaged player is TWO processes (PyInstaller bootloader
parent plus the child that owns the SDL window), so any kill uses taskkill /T or
the visible window survives and the next launch collides with it.
Verified in the packaged exe over a 7-hour run: 170 playlist restarts, 1365
items, 171 web links launched/visible/ended with zero failures, and no crashes,
no hangs and no leaked browser processes.
Tests: windows/test_video_hang.py and windows/test_watchdog.py. The hang test
deliberately holds the heartbeat open with an exclusive Windows lock (share mode
0) so the player's own write fails - backdating the file's mtime does NOT
simulate a hang, because the healthy player rewrites it immediately and the test
would then pass for the wrong reason.
2307 lines
91 KiB
Python
2307 lines
91 KiB
Python
"""
|
|
Kiwy Signage Player - Windows Entry Point
|
|
------------------------------------------
|
|
Patches platform-specific code and environment for Windows before launching
|
|
the original Kivy-based signage player application.
|
|
|
|
Usage:
|
|
python run_win.py (for development/testing)
|
|
run_win.exe (after PyInstaller build)
|
|
"""
|
|
|
|
import ctypes
|
|
import os
|
|
import sys
|
|
import platform
|
|
import tempfile
|
|
import subprocess
|
|
import shutil
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
|
|
def _set_process_dpi_awareness():
|
|
"""Declare per-monitor DPI awareness so Kivy/SDL2 see the TRUE resolution.
|
|
|
|
Without this, on a display scaled above 100% (e.g. 1920x1080 @ 125%),
|
|
Windows virtualizes the app to the scaled-down size (1536x864). Kivy then
|
|
sizes the content area to the virtualized resolution, leaving a black strip
|
|
on one side and making images/videos render at the wrong size.
|
|
|
|
Prefer PROCESS_PER_MONITOR_DPI_AWARE_V2 (2); fall back to
|
|
PROCESS_PER_MONITOR_DPI_AWARE (1) and PROCESS_SYSTEM_DPI_AWARE (0).
|
|
"""
|
|
try:
|
|
try:
|
|
# Windows 10 1703+
|
|
aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2
|
|
ctypes.windll.shcore.SetProcessDpiAwareness(aware)
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
# Windows 8.1 / fallback
|
|
ctypes.windll.user32.SetProcessDPIAware()
|
|
return
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Declare DPI awareness BEFORE any Kivy/SDL import ────────────────
|
|
_set_process_dpi_awareness()
|
|
|
|
# Make SDL2 use the native (physical) pixel size instead of the DPI-scaled
|
|
# virtual size. Without this, on a 125%-scaled display the window and the
|
|
# Kivy content area are sized to the virtualized resolution (1536x864) even
|
|
# when the monitor is 1920x1080, leaving a black strip and seeing the
|
|
# desktop through the gap.
|
|
os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1')
|
|
|
|
# ── Windows-native card reader (Raw Input API + LL-hook fallback) ───
|
|
# Imported here (not lazily) so PyInstaller bundles it via run_win.py's
|
|
# module graph. It replaces the Linux-only evdev-based CardReader.
|
|
from win_card_reader import WindowsCardReader
|
|
|
|
|
|
def _show_error_box(title, message):
|
|
"""Show a Windows message box with the error (visible even without console)."""
|
|
try:
|
|
ctypes.windll.user32.MessageBoxW(0, message, title, 0x10) # MB_ICONERROR
|
|
except Exception:
|
|
pass
|
|
|
|
# --- Ensure we are on Windows; warn if not ---
|
|
if platform.system() != 'Windows':
|
|
print(f"WARNING: This entry point is designed for Windows. Detected: {platform.system()}")
|
|
|
|
# =====================================================================
|
|
# 1. Set Windows-compatible environment variables BEFORE Kivy imports
|
|
# =====================================================================
|
|
|
|
# Video driver: Use 'windib' or 'angle' (DirectX via ANGLE) for Windows
|
|
os.environ.setdefault('SDL_VIDEODRIVER', 'windows')
|
|
# Audio driver: DirectSound for Windows
|
|
os.environ.setdefault('SDL_AUDIODRIVER', 'directsound')
|
|
# Prevent screensaver
|
|
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
|
|
# Video backend via ffpyplayer
|
|
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
|
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
|
|
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
|
|
# Kivy window backend: prefer SDL2 on Windows
|
|
os.environ.setdefault('KIVY_WINDOW', 'sdl2')
|
|
# OpenGL
|
|
os.environ.setdefault('KIVY_GL_BACKEND', 'angle_sdl2')
|
|
|
|
# =====================================================================
|
|
# 2. Patch the evdev import — it is Linux-only. We provide a dummy
|
|
# module so that `from evdev import ...` will not crash on Windows.
|
|
# =====================================================================
|
|
class _DummyEvdev:
|
|
"""Fake evdev module that raises ImportError for all meaningful uses."""
|
|
|
|
class InputDevice:
|
|
def __init__(self, *a, **kw):
|
|
raise ImportError("evdev is not available on Windows")
|
|
|
|
class ecodes:
|
|
EV_KEY = 1
|
|
EV_ABS = 3
|
|
|
|
def categorize(self, *a, **kw):
|
|
raise ImportError("evdev is not available on Windows")
|
|
|
|
def list_devices(self):
|
|
return []
|
|
|
|
|
|
class _FakeEvdevInputDevice:
|
|
pass
|
|
|
|
|
|
# Inject the fake evdev module into sys.modules so that main.py's
|
|
# `try: import evdev` succeeds but EVDEV_AVAILABLE stays False.
|
|
_evdev_dummy = _DummyEvdev()
|
|
sys.modules['evdev'] = _evdev_dummy
|
|
sys.modules['evdev.InputDevice'] = _FakeEvdevInputDevice
|
|
|
|
# =====================================================================
|
|
# 3. Provide a Windows implementation of screen activity signaling
|
|
# We monkey-patch the SignagePlayer.signal_screen_activity method
|
|
# after the class is defined but before it's used, by hooking into
|
|
# the import machinery.
|
|
# =====================================================================
|
|
|
|
# We'll store a reference to the original module's signal_screen_activity
|
|
# so we can replace it after import. This is done inside _patch_main().
|
|
|
|
# Keep-awake state so we can restore the screensaver on exit.
|
|
_SAVED_SCREENSAVER_ACTIVE = None # True/False once read; None = unknown
|
|
|
|
|
|
def _disable_windows_screensaver():
|
|
"""Disable the Windows screensaver so the lock screen never appears.
|
|
|
|
On Windows the lock screen is tied to the screensaver: when the screen
|
|
'turns off' or the screensaver runs with 'On resume, display logon
|
|
screen', Windows shows the lock. Disabling the screensaver and keeping
|
|
the display awake (SetThreadExecutionState ES_DISPLAY_REQUIRED) prevents
|
|
both the blank screen and the lock screen.
|
|
"""
|
|
global _SAVED_SCREENSAVER_ACTIVE
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
SPI_GETSCREENSAVEACTIVE = 0x0010
|
|
SPI_SETSCREENSAVEACTIVE = 0x0011
|
|
SPI_SETSCREENSAVERUNSAFE = 0x0013
|
|
SPIF_SENDCHANGE = 0x2
|
|
|
|
user32.SystemParametersInfoW.argtypes = [
|
|
ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint
|
|
]
|
|
user32.SystemParametersInfoW.restype = ctypes.c_int
|
|
|
|
# Remember the original screensaver state once, so we can restore it
|
|
# when the app exits.
|
|
if _SAVED_SCREENSAVER_ACTIVE is None:
|
|
pval = ctypes.c_int(0)
|
|
if user32.SystemParametersInfoW(SPI_GETSCREENSAVEACTIVE, 0,
|
|
ctypes.byref(pval), 0):
|
|
_SAVED_SCREENSAVER_ACTIVE = bool(pval.value)
|
|
|
|
# Disable the screensaver (uiParam=0) and mark it safe to toggle
|
|
# without a password prompt (SPI_SETSCREENSAVERUNSAFE).
|
|
user32.SystemParametersInfoW(SPI_SETSCREENSAVEACTIVE, 0, 0,
|
|
SPIF_SENDCHANGE)
|
|
user32.SystemParametersInfoW(SPI_SETSCREENSAVERUNSAFE, 0, 0,
|
|
SPIF_SENDCHANGE)
|
|
except Exception:
|
|
pass # non-critical
|
|
|
|
|
|
def _restore_windows_screensaver():
|
|
"""Restore the screensaver state the app found at startup."""
|
|
global _SAVED_SCREENSAVER_ACTIVE
|
|
if _SAVED_SCREENSAVER_ACTIVE is None:
|
|
return
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
SPI_SETSCREENSAVEACTIVE = 0x0011
|
|
SPIF_SENDCHANGE = 0x2
|
|
user32.SystemParametersInfoW.argtypes = [
|
|
ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint
|
|
]
|
|
user32.SystemParametersInfoW(
|
|
SPI_SETSCREENSAVEACTIVE,
|
|
1 if _SAVED_SCREENSAVER_ACTIVE else 0, 0, SPIF_SENDCHANGE)
|
|
_SAVED_SCREENSAVER_ACTIVE = None
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _windows_screen_activity(self, dt):
|
|
"""Windows keep-awake: prevent display-off, sleep AND lock screen.
|
|
|
|
SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED|ES_DISPLAY_REQUIRED)
|
|
tells Windows the system and display must stay on. Combined with disabling
|
|
the screensaver (SystemParametersInfo), this prevents:
|
|
- the display turning off,
|
|
- the machine sleeping,
|
|
- the lock screen (which appears when the screen 'turns off' or the
|
|
screensaver runs with logon-on-resume).
|
|
Called every ~20s by the existing Clock.schedule_interval.
|
|
"""
|
|
try:
|
|
# ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
|
|
ES_CONTINUOUS = 0x80000000
|
|
ES_SYSTEM_REQUIRED = 0x00000001
|
|
ES_DISPLAY_REQUIRED = 0x00000002
|
|
ctypes.windll.kernel32.SetThreadExecutionState(
|
|
ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
|
|
)
|
|
except Exception:
|
|
pass # non-critical
|
|
# Disable the screensaver / lock screen (re-asserted every tick in case
|
|
# the OS or another process re-enabled it).
|
|
_disable_windows_screensaver()
|
|
|
|
|
|
# ── Try to import the embedded CEF browser ──────────────────────────
|
|
_CEF_BROWSER = None
|
|
|
|
def _get_cef_browser():
|
|
"""Return the shared CefBrowser singleton, or None if unavailable.
|
|
|
|
v2 embeds CEF as a CHILD WINDOW inside Kivy's SDL_app window.
|
|
This means: no separate taskbar entry, no z-order fighting,
|
|
no desktop flash, no taskkill needed.
|
|
"""
|
|
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
|
|
|
|
|
|
# ── Embedded WebView2 (Edge) browser ────────────────────────────────
|
|
_WEBVIEW2_BROWSER = None
|
|
|
|
#: How long a weblink will wait for a Runtime that is still installing.
|
|
_WEBVIEW2_INSTALL_WAIT = 300.0
|
|
|
|
|
|
def _ensure_webview2_runtime_async():
|
|
"""Start installing the WebView2 Runtime if it is missing.
|
|
|
|
Called once at start-up. The install runs on a background thread so a
|
|
machine without the Runtime can install it while the player is still
|
|
syncing its playlist, rather than freezing the UI on the first weblink.
|
|
"""
|
|
try:
|
|
import webview2_runtime
|
|
except Exception as exc:
|
|
_log(f"WebView2 runtime module unavailable: {exc}")
|
|
return
|
|
try:
|
|
_log(webview2_runtime.describe())
|
|
webview2_runtime.ensure_runtime_async()
|
|
except Exception as exc:
|
|
_log(f"WebView2 runtime check failed (non-fatal): {exc}")
|
|
|
|
|
|
def _get_webview2_browser(force_new=False):
|
|
"""Return the shared WebView2Browser singleton, or None if unavailable.
|
|
|
|
WebView2 renders as a CHILD HWND of Kivy's SDL window, which is what makes
|
|
it immune to the subprocess weblink bugs (background window, instant
|
|
hand-off exit, z-order fights, leaked browsers).
|
|
"""
|
|
global _WEBVIEW2_BROWSER
|
|
if force_new:
|
|
old, _WEBVIEW2_BROWSER = _WEBVIEW2_BROWSER, None
|
|
if old is not None:
|
|
try:
|
|
old.shutdown()
|
|
except Exception:
|
|
pass
|
|
if _WEBVIEW2_BROWSER is None:
|
|
try:
|
|
from webview2_browser import WebView2Browser
|
|
except Exception as exc:
|
|
_log(f"WebView2 module unavailable: {exc}")
|
|
return None
|
|
|
|
if not WebView2Browser.is_available():
|
|
# The SDK is bundled but the Runtime may still be installing
|
|
# (a machine that shipped without it). Wait here rather than
|
|
# silently downgrading to the leaky Chrome/Edge engine — this runs
|
|
# on the watcher thread, not the Kivy main thread.
|
|
if _wait_for_webview2_runtime():
|
|
pass # installed in the meantime; retry below
|
|
if not WebView2Browser.is_available():
|
|
reason = getattr(WebView2Browser, '_import_error', None) or 'unavailable'
|
|
_log(f"WebView2 not usable: {reason}")
|
|
return None
|
|
|
|
try:
|
|
data_dir = os.environ.get('KIWY_DATA_DIR', os.getcwd())
|
|
_WEBVIEW2_BROWSER = WebView2Browser(
|
|
hwnd_provider=_find_kivy_hwnd,
|
|
user_data_dir=os.path.join(data_dir, '.webview2-profile'),
|
|
)
|
|
except Exception as exc:
|
|
_log(f"WebView2 init failed: {exc}")
|
|
return None
|
|
return _WEBVIEW2_BROWSER
|
|
|
|
|
|
def _wait_for_webview2_runtime(timeout=_WEBVIEW2_INSTALL_WAIT):
|
|
"""Block while a Runtime install is in flight. True if it became available."""
|
|
try:
|
|
import webview2_runtime
|
|
except Exception:
|
|
return False
|
|
state = webview2_runtime.get_state()
|
|
if not state.get('installing'):
|
|
return False
|
|
_log("WebView2: waiting for the Runtime install to finish...")
|
|
ok = webview2_runtime.wait_for_install(timeout)
|
|
_log(f"WebView2: Runtime install wait finished (available={ok})")
|
|
return ok
|
|
|
|
|
|
def _windows_find_browser():
|
|
"""Find Chrome or Edge executable on Windows for weblink support.
|
|
|
|
Returns the path to the browser or None.
|
|
"""
|
|
# Common install locations
|
|
candidates = [
|
|
# Chrome
|
|
os.path.expandvars(r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'),
|
|
os.path.expandvars(r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'),
|
|
os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
|
|
# Edge
|
|
os.path.expandvars(r'%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe'),
|
|
os.path.expandvars(r'%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe'),
|
|
os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'),
|
|
]
|
|
for path in candidates:
|
|
if os.path.isfile(path):
|
|
return path
|
|
|
|
# Fallback: try PATH
|
|
which = shutil.which('chrome') or shutil.which('msedge') or shutil.which('google-chrome')
|
|
if which:
|
|
return which
|
|
|
|
return None
|
|
|
|
|
|
# ── Win32 API helpers via ctypes ─────────────────────────────────────
|
|
class _Win32Overlay:
|
|
"""Fullscreen black overlay window to mask desktop during transitions.
|
|
|
|
When switching to/away from Chromium, the browser window appears/disappears
|
|
and there is a brief moment where the desktop is visible. This overlay
|
|
covers that flash with a pure-black borderless always-on-top Win32 window.
|
|
|
|
For weblink open, the overlay is kept up by the adapter's
|
|
`on_before_launch` and only hidden once the browser window is confirmed
|
|
visible (see `_WinChromeAdapter.wait_visible`), so the desktop is never
|
|
exposed while the browser is still starting.
|
|
"""
|
|
|
|
_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
|
|
|
|
|
|
class _Win32Backdrop:
|
|
"""Persistent fullscreen black window shown at player startup.
|
|
|
|
Sits just above the host desktop but BELOW the Kivy window and the kiosk
|
|
browser (placed at HWND_BOTTOM). Because it stays up for the whole session,
|
|
any gap while the weblink browser loads or unloads reveals this clean black
|
|
screen instead of the host desktop — no more desktop flash during the
|
|
browser load/unload transitions.
|
|
"""
|
|
|
|
_hwnd = None
|
|
|
|
@classmethod
|
|
def show(cls):
|
|
"""Create (once) the fullscreen black backdrop above the desktop."""
|
|
if cls._hwnd is not None:
|
|
return # already showing
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
kernel32 = ctypes.windll.kernel32
|
|
hinstance = kernel32.GetModuleHandleW(None)
|
|
screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN
|
|
screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN
|
|
|
|
hwnd = user32.CreateWindowExW(
|
|
0x00000080, # WS_EX_TOOLWINDOW (no taskbar entry)
|
|
b'#32770', # dialog class (always available)
|
|
b'KiwyBackdrop',
|
|
0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE
|
|
0, 0, screen_w, screen_h,
|
|
0, 0, hinstance, 0,
|
|
)
|
|
if not hwnd:
|
|
return
|
|
|
|
# Paint it black
|
|
gdi32 = ctypes.windll.gdi32
|
|
hdc = user32.GetDC(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(hwnd, hdc)
|
|
|
|
# Keep it BELOW the app windows (HWND_BOTTOM = 1) so Kivy and the
|
|
# kiosk browser draw on top, but still above the desktop.
|
|
user32.SetWindowPos(
|
|
hwnd, 1, 0, 0, screen_w, screen_h,
|
|
0x0002 | 0x0040, # SWP_NOMOVE | SWP_SHOWWINDOW
|
|
)
|
|
user32.ShowWindow(hwnd, 1)
|
|
user32.UpdateWindow(hwnd)
|
|
cls._hwnd = hwnd
|
|
except Exception:
|
|
cls._hwnd = None # failed gracefully
|
|
|
|
@classmethod
|
|
def hide(cls):
|
|
"""Destroy the backdrop (only at application exit)."""
|
|
if cls._hwnd is None:
|
|
return
|
|
try:
|
|
ctypes.windll.user32.DestroyWindow(cls._hwnd)
|
|
except Exception:
|
|
pass
|
|
cls._hwnd = None
|
|
|
|
|
|
# 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
|
|
|
|
# ── Low-level keyboard lockdown (production / kiosk mode) ───────────
|
|
# WH_KEYBOARD_LL constants and virtual-key codes used to swallow host
|
|
# shortcuts (Alt+F4, Alt+Tab, Win, Ctrl+Esc) while the player is the
|
|
# only thing the operator should interact with.
|
|
_WH_KEYBOARD_LL = 13
|
|
_WM_KEYDOWN = 0x0100
|
|
_WM_KEYUP = 0x0101
|
|
_WM_SYSKEYDOWN = 0x0104
|
|
_WM_SYSKEYUP = 0x0105
|
|
_HC_ACTION = 0
|
|
_VK_TAB = 0x09
|
|
_VK_ESCAPE = 0x1B
|
|
_VK_LWIN = 0x5B
|
|
_VK_RWIN = 0x5C
|
|
_VK_F4 = 0x73
|
|
_VK_LCONTROL = 0xA2
|
|
_VK_RCONTROL = 0xA3
|
|
_VK_LMENU = 0xA4 # left Alt
|
|
_VK_RMENU = 0xA5 # right Alt
|
|
|
|
# Holds the Win32 state for the active keyboard hook (installed while
|
|
# production mode is ON). Kept at module scope so the hook proc can be
|
|
# referenced without being garbage collected.
|
|
_KB_HOOK = {
|
|
'proc': None,
|
|
'handle': None,
|
|
'active': False,
|
|
}
|
|
|
|
|
|
def _kb_hook_callback(nCode, wParam, lParam):
|
|
"""Low-level keyboard hook callback.
|
|
|
|
Called on the thread that installed the hook for every keyboard event.
|
|
We swallow the host-level shortcuts that would let the operator escape
|
|
the kiosk player:
|
|
- Alt+F4 (close the player / focus-steal)
|
|
- Alt+Tab (switch to another app)
|
|
- Ctrl+Esc (open Start menu)
|
|
- Windows key (open Start menu)
|
|
- Alt+Escape (cycle windows)
|
|
Returns 1 (consume) for those keys, otherwise passes the event through.
|
|
|
|
NOTE: this runs inside a ctypes callback. If it raises, the exception
|
|
crosses the native boundary and can crash the process, so every path is
|
|
guarded and the hook always forwards with CallNextHookEx.
|
|
"""
|
|
try:
|
|
if nCode == _HC_ACTION:
|
|
vk_code = ctypes.cast(
|
|
lParam, ctypes.POINTER(ctypes.c_ulong)
|
|
).contents.value & 0xFFFF
|
|
# Full key state so we can detect modifier combos reliably.
|
|
keys = {
|
|
'lctrl': _is_key_down(_VK_LCONTROL),
|
|
'rctrl': _is_key_down(_VK_RCONTROL),
|
|
'lalt': _is_key_down(_VK_LMENU),
|
|
'ralt': _is_key_down(_VK_RMENU),
|
|
'lwin': _is_key_down(_VK_LWIN),
|
|
'rwin': _is_key_down(_VK_RWIN),
|
|
}
|
|
ctrl = keys['lctrl'] or keys['rctrl']
|
|
alt = keys['lalt'] or keys['ralt']
|
|
win = keys['lwin'] or keys['rwin']
|
|
|
|
# Block the dangerous host shortcuts.
|
|
if vk_code == _VK_F4 and alt:
|
|
return 1 # Alt+F4
|
|
if vk_code == _VK_TAB and alt:
|
|
return 1 # Alt+Tab
|
|
if vk_code == _VK_ESCAPE and ctrl:
|
|
return 1 # Ctrl+Esc
|
|
if vk_code == _VK_ESCAPE and alt:
|
|
return 1 # Alt+Esc
|
|
if win:
|
|
return 1 # Windows key (left or right)
|
|
|
|
# NOTE: Ctrl+Alt+Delete (SAS) is handled by the OS before any
|
|
# user-mode hook can see it — it cannot be blocked from here.
|
|
except Exception:
|
|
# Never let a callback exception cross the native boundary.
|
|
pass
|
|
try:
|
|
return ctypes.windll.user32.CallNextHookEx(
|
|
_KB_HOOK['handle'], nCode, wParam, lParam
|
|
)
|
|
except Exception:
|
|
return 1 # last resort: consume rather than crash
|
|
|
|
|
|
def _is_key_down(vk):
|
|
"""Return True if the given virtual-key is currently pressed."""
|
|
try:
|
|
state = ctypes.windll.user32.GetAsyncKeyState(vk)
|
|
# 0x8000 = most significant bit set (key is down)
|
|
return bool(state & 0x8000)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _install_kb_lockdown():
|
|
"""Install the low-level keyboard hook for kiosk mode."""
|
|
global _KB_HOOK
|
|
if _KB_HOOK['active']:
|
|
return
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
HOOKPROC = ctypes.WINFUNCTYPE(
|
|
ctypes.c_long, ctypes.c_int, ctypes.c_uint, ctypes.c_ulong
|
|
)
|
|
proc = HOOKPROC(_kb_hook_callback)
|
|
hmodule = ctypes.windll.kernel32.GetModuleHandleW(None)
|
|
handle = user32.SetWindowsHookExW(
|
|
_WH_KEYBOARD_LL, proc, hmodule, 0
|
|
)
|
|
if not handle:
|
|
return False
|
|
_KB_HOOK['proc'] = proc
|
|
_KB_HOOK['handle'] = handle
|
|
_KB_HOOK['active'] = True
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _uninstall_kb_lockdown():
|
|
"""Remove the low-level keyboard hook (dev mode)."""
|
|
global _KB_HOOK
|
|
if not _KB_HOOK['active']:
|
|
return
|
|
try:
|
|
if _KB_HOOK['handle']:
|
|
ctypes.windll.user32.UnhookWindowsHookEx(_KB_HOOK['handle'])
|
|
except Exception:
|
|
pass
|
|
_KB_HOOK['handle'] = None
|
|
_KB_HOOK['proc'] = None
|
|
_KB_HOOK['active'] = False
|
|
|
|
|
|
def _windows_apply_kiosk_mode(self, enabled):
|
|
"""Windows-specific kiosk lockdown in addition to the base logic.
|
|
|
|
Installs/uninstalls the low-level keyboard hook that swallows
|
|
Alt+F4, Alt+Tab, Win, Ctrl+Esc while the player is in production
|
|
mode. Also calls the base implementation for the cross-platform
|
|
pieces (exit_on_escape, on_request_close guard, Ctrl+C ignore).
|
|
"""
|
|
# Call the base (cross-platform) kiosk logic first.
|
|
base_apply = getattr(
|
|
_patch_main, '_base_apply_kiosk_mode', None
|
|
) or _base_apply_kiosk_mode
|
|
base_apply(self, enabled)
|
|
|
|
if enabled:
|
|
_install_kb_lockdown()
|
|
Logger.info(
|
|
"run_win: Windows keyboard lockdown ACTIVE "
|
|
"(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)"
|
|
)
|
|
else:
|
|
_uninstall_kb_lockdown()
|
|
Logger.info("run_win: Windows keyboard lockdown DISABLED")
|
|
|
|
|
|
# Default cross-platform kiosk applier (kept here so the main-module patch
|
|
# can reference it; the real implementation lives in main.py, and we simply
|
|
# forward to it when the patched method is not available).
|
|
def _base_apply_kiosk_mode(self, enabled):
|
|
self.config['production_mode'] = bool(enabled)
|
|
if enabled:
|
|
try:
|
|
from kivy.config import Config
|
|
Config.set('kivy', 'exit_on_escape', '0')
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import signal
|
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
import signal
|
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _force_foreground_sendinput(hwnd):
|
|
"""Bypass the Windows foreground lock using the SendInput trick.
|
|
|
|
Windows only lets a process call SetForegroundWindow() if it is the "last
|
|
input process" — i.e. it processed the most recent keyboard/mouse input.
|
|
A background signage player that never gets user input can therefore be
|
|
denied foreground forever once another window (like a cycling kiosk
|
|
Chrome) owns the input queue. That is exactly the "focus lost after many
|
|
weblink cycles" bug.
|
|
|
|
The classic workaround: synthesize a real input event (an invisible
|
|
Alt-key press) via SendInput. This makes the *current* process the last
|
|
input process, so the subsequent SetForegroundWindow() is allowed.
|
|
|
|
NOTE: This is the same technique used by AutoHotkey and countless kiosk
|
|
apps. It briefly fakes a keypress, but since we send only a modifier key
|
|
(Alt) that is immediately released, the user never sees it.
|
|
"""
|
|
try:
|
|
# 1) Send a harmless Alt keydown + keyup so THIS process becomes the
|
|
# last-input process.
|
|
user32 = ctypes.windll.user32
|
|
|
|
# NOTE: The Win32 INPUT struct is a 32-byte union (mouse/keyboard/
|
|
# hardware) preceded by a 4-byte type and 4 bytes of padding on x64,
|
|
# for a total of 40 bytes. The previous implementation defined INPUT
|
|
# as just type+KEYBDINPUT (32 bytes) — SendInput() rejected the
|
|
# undersized buffer (cbSize mismatch) so the fake Alt keypress was
|
|
# NEVER delivered, and the foreground lock was never defeated. That is
|
|
# why focus was permanently lost after enough weblink cycles.
|
|
if ctypes.sizeof(ctypes.c_void_p) == 8: # 64-bit
|
|
class KEYBDINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('wVk', ctypes.c_ushort),
|
|
('wScan', ctypes.c_ushort),
|
|
('dwFlags', ctypes.c_ulong),
|
|
('time', ctypes.c_ulong),
|
|
('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR
|
|
]
|
|
|
|
class MOUSEINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('dx', ctypes.c_long),
|
|
('dy', ctypes.c_long),
|
|
('mouseData', ctypes.c_ulong),
|
|
('dwFlags', ctypes.c_ulong),
|
|
('time', ctypes.c_ulong),
|
|
('dwExtraInfo', ctypes.c_ulonglong), # ULONG_PTR
|
|
]
|
|
|
|
class HARDWAREINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('uMsg', ctypes.c_ulong),
|
|
('wParamL', ctypes.c_ushort),
|
|
('wParamH', ctypes.c_ushort),
|
|
]
|
|
|
|
class INPUTUNION(ctypes.Union):
|
|
_fields_ = [
|
|
('mi', MOUSEINPUT),
|
|
('ki', KEYBDINPUT),
|
|
('hi', HARDWAREINPUT),
|
|
]
|
|
|
|
class INPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('type', ctypes.c_ulong),
|
|
('u', INPUTUNION),
|
|
]
|
|
else: # 32-bit fallback
|
|
class KEYBDINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('wVk', ctypes.c_ushort),
|
|
('wScan', ctypes.c_ushort),
|
|
('dwFlags', ctypes.c_ulong),
|
|
('time', ctypes.c_ulong),
|
|
('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR
|
|
]
|
|
|
|
class MOUSEINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('dx', ctypes.c_long),
|
|
('dy', ctypes.c_long),
|
|
('mouseData', ctypes.c_ulong),
|
|
('dwFlags', ctypes.c_ulong),
|
|
('time', ctypes.c_ulong),
|
|
('dwExtraInfo', ctypes.c_ulong), # ULONG_PTR
|
|
]
|
|
|
|
class HARDWAREINPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('uMsg', ctypes.c_ulong),
|
|
('wParamL', ctypes.c_ushort),
|
|
('wParamH', ctypes.c_ushort),
|
|
]
|
|
|
|
class INPUTUNION(ctypes.Union):
|
|
_fields_ = [
|
|
('mi', MOUSEINPUT),
|
|
('ki', KEYBDINPUT),
|
|
('hi', HARDWAREINPUT),
|
|
]
|
|
|
|
class INPUT(ctypes.Structure):
|
|
_fields_ = [
|
|
('type', ctypes.c_ulong),
|
|
('u', INPUTUNION),
|
|
]
|
|
|
|
INPUT_KEYBOARD = 1
|
|
KEYEVENTF_KEYUP = 0x0002
|
|
VK_MENU = 0x12 # Alt
|
|
|
|
# Send Alt down
|
|
inp_down = INPUT()
|
|
inp_down.type = INPUT_KEYBOARD
|
|
inp_down.u.ki.wVk = VK_MENU
|
|
# Send Alt up
|
|
inp_up = INPUT()
|
|
inp_up.type = INPUT_KEYBOARD
|
|
inp_up.u.ki.wVk = VK_MENU
|
|
inp_up.u.ki.dwFlags = KEYEVENTF_KEYUP
|
|
|
|
arr = (INPUT * 2)(inp_down, inp_up)
|
|
user32.SendInput(2, ctypes.byref(arr), ctypes.sizeof(INPUT))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _bring_hwnd_to_front(hwnd, use_topmost_flash=True):
|
|
"""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
|
|
escalate through several methods:
|
|
|
|
Method 1 — AttachThreadInput bypass: attach our calling thread (and the
|
|
target window's thread) to the current foreground window's
|
|
input thread before calling SetForegroundWindow.
|
|
Method 2 — SendInput unlock: fake an Alt keypress so our process
|
|
becomes the last-input process, then SetForegroundWindow.
|
|
This defeats the foreground lock even when another process
|
|
(e.g. a repeatedly cycling kiosk Chrome) owns the input.
|
|
Method 3 — Z-order flash: BringWindowToTop + SetWindowPos(TOPMOST then
|
|
NOTOPMOST) which reorders Z-order and works even when the
|
|
process is backgrounded.
|
|
|
|
Returns True if the window is the foreground window afterwards, False
|
|
otherwise (so callers can retry).
|
|
"""
|
|
if not hwnd:
|
|
return False
|
|
user32 = ctypes.windll.user32
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
# If minimized, restore first so the window can actually be shown.
|
|
if user32.IsIconic(hwnd):
|
|
user32.ShowWindowAsync(hwnd, _SW_RESTORE)
|
|
user32.ShowWindow(hwnd, _SW_RESTORE)
|
|
|
|
# ── Method 1: SetForegroundWindow with the input-thread bypass ──
|
|
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
|
|
|
|
# ── Method 2: SendInput unlock (beats the foreground lock) ──
|
|
# Only bother if we still don't have foreground after Method 1.
|
|
try:
|
|
if user32.GetForegroundWindow() != hwnd:
|
|
_force_foreground_sendinput(hwnd)
|
|
user32.SetForegroundWindow(hwnd)
|
|
user32.BringWindowToTop(hwnd)
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Method 3: Z-order + restore (reliable from a background process) ──
|
|
user32.ShowWindowAsync(hwnd, _SW_SHOWNORMAL)
|
|
user32.ShowWindow(hwnd, _SW_SHOWNORMAL)
|
|
user32.BringWindowToTop(hwnd)
|
|
if use_topmost_flash:
|
|
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)
|
|
|
|
# ── Verify ──
|
|
try:
|
|
return user32.GetForegroundWindow() == hwnd
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _force_kivy_fullscreen_bounds():
|
|
"""Force the Kivy/SDL window to cover the ENTIRE physical monitor.
|
|
|
|
Kivy's 'fullscreen' config can leave the SDL window at the DPI-virtualized
|
|
size (e.g. 1536x864 on a 1920x1080 display at 125% scaling), which shows a
|
|
black strip and lets the desktop bleed through. This resizes + repositions
|
|
the SDL window to the physical monitor bounds using Win32 directly, which
|
|
works regardless of how SDL interpreted the DPI config.
|
|
|
|
Returns True on success, False otherwise (so callers can retry after the
|
|
window is created).
|
|
"""
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
# SM_CXSCREEN/SM_CYSCREEN return PHYSICAL pixels once the process is
|
|
# DPI-aware (we set that at startup), so these are the true bounds.
|
|
w = user32.GetSystemMetrics(0) # SM_CXSCREEN
|
|
h = user32.GetSystemMetrics(1) # SM_CYSCREEN
|
|
if w <= 0 or h <= 0:
|
|
return False
|
|
|
|
hwnd = _find_kivy_hwnd()
|
|
if hwnd is None:
|
|
return False
|
|
|
|
# Cheap check: skip SetWindowPos if the window already covers the full
|
|
# monitor at 0,0 (the 2s sizing guardian calls this repeatedly, so we
|
|
# must not churn the window when the size is already correct).
|
|
try:
|
|
import win32gui
|
|
cur = win32gui.GetWindowRect(hwnd) # (left, top, right, bottom)
|
|
if (cur[0] == 0 and cur[1] == 0
|
|
and (cur[2] - cur[0]) == w and (cur[3] - cur[1]) == h):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Remove any maximized flag first, then size + position at 0,0 to the
|
|
# full monitor size. SWP_NOZORDER keeps z-order unchanged.
|
|
SWP_NOZORDER = 0x0004
|
|
SWP_FRAMECHANGED = 0x0020
|
|
user32.SetWindowPos(
|
|
hwnd, 0, 0, 0, int(w), int(h),
|
|
SWP_NOZORDER | SWP_FRAMECHANGED,
|
|
)
|
|
# Ensure it's visible + restored (not minimized).
|
|
user32.ShowWindow(hwnd, _SW_RESTORE)
|
|
user32.ShowWindow(hwnd, _SW_SHOWNORMAL)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _reassert_kivy_fullscreen(self=None):
|
|
"""Restore the Kivy window to full physical-monitor bounds AND re-sync the
|
|
Kivy content area after a weblink browser closes.
|
|
|
|
WHY: Chrome/Edge opens its own fullscreen kiosk window over Kivy. When that
|
|
browser window is destroyed, SDL can leave the Kivy window at a wrong /
|
|
DPI-virtualized size (e.g. 1536x864 on a 1920x1080 display), and the Kivy
|
|
content_area + screen_width/height stay at the stale size -> black strip +
|
|
desktop bleed-through + wrong image/video scaling. This forcibly resizes
|
|
the SDL window to the monitor bounds and re-syncs Kivy's layout.
|
|
|
|
self: the SignagePlayer instance (optional; used to re-sync its ids).
|
|
"""
|
|
ok = False
|
|
try:
|
|
ok = _force_kivy_fullscreen_bounds()
|
|
except Exception:
|
|
ok = False
|
|
|
|
# Re-sync the Kivy content layout to the true monitor bounds. The native
|
|
# SetWindowPos above drives SDL's WM_SIZE -> Kivy Window.size -> _update_size
|
|
# automatically; we also set the properties directly here as immediate
|
|
# insurance so the content_area never renders at a stale size in the frame
|
|
# right after a weblink closes.
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
w = user32.GetSystemMetrics(0)
|
|
h = user32.GetSystemMetrics(1)
|
|
if w > 0 and h > 0:
|
|
if self is not None:
|
|
try:
|
|
self.screen_width = w
|
|
self.screen_height = h
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.size = (w, h)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.ids.content_area.size = (w, h)
|
|
except Exception:
|
|
pass
|
|
ok = True
|
|
except Exception:
|
|
pass
|
|
return ok
|
|
|
|
|
|
def _find_kivy_hwnd():
|
|
"""Return the HWND of the Kivy/SDL window, or None.
|
|
|
|
IMPORTANT: The previous implementation matched ANY window whose class is
|
|
'SDL_app' OR whose title contains 'Kiwy'/'Signage'. Because this build runs
|
|
with console=True, the exe's own console window has the title
|
|
'...\\KiwySignagePlayer\\KiwySignagePlayer.exe' — which CONTAINS both
|
|
'Kiwy' and 'Signage'. EnumWindows lists top-level windows in Z-order, so
|
|
the console window could be returned as hwnd_list[-1], and
|
|
_bring_hwnd_to_front() would then raise the CONSOLE window instead of the
|
|
media window. That is the "app focuses the console instead of the widget"
|
|
symptom.
|
|
|
|
Fix: only ever return a real SDL window (class 'SDL_app' or
|
|
'SDL_app_arm'/'SDL_app_x11' variants). Never match on the title, and
|
|
explicitly exclude the console window class ('ConsoleWindowClass').
|
|
"""
|
|
try:
|
|
import win32gui
|
|
except Exception:
|
|
return None
|
|
|
|
# Class names of Kivy's SDL2 window on Windows.
|
|
SDL_CLASSES = ('SDL_app', 'SDL_app_x11', 'SDL_app_arm')
|
|
|
|
sdl_windows = []
|
|
# Fallback: in case the SDL class name differs, remember any non-console
|
|
# window owned by this process whose title mentions the app.
|
|
our_pid = None
|
|
try:
|
|
import os as _os
|
|
our_pid = _os.getpid()
|
|
except Exception:
|
|
our_pid = None
|
|
|
|
def _enum_cb(hwnd, _):
|
|
try:
|
|
cls = win32gui.GetClassName(hwnd)
|
|
title = win32gui.GetWindowText(hwnd)
|
|
except Exception:
|
|
return
|
|
# Skip the console host window outright — it must never be the target.
|
|
if cls in ('ConsoleWindowClass', 'CASCADIA_HOSTING_WINDOW_CLASS'):
|
|
return
|
|
if cls.startswith('SDL_app') or cls in SDL_CLASSES:
|
|
sdl_windows.append(hwnd)
|
|
return
|
|
# Last-resort fallback: a visible, non-tool window of OUR process whose
|
|
# title contains the app name. This catches renamed/ALT-styled SDL
|
|
# windows without ever matching the console.
|
|
if our_pid is not None:
|
|
try:
|
|
if win32gui.GetWindowThreadProcessId(hwnd, None)[1] != our_pid:
|
|
return
|
|
except Exception:
|
|
return
|
|
if "Kiwy" in title or "Signage" in title:
|
|
sdl_windows.append(hwnd)
|
|
|
|
try:
|
|
win32gui.EnumWindows(_enum_cb, None)
|
|
except Exception:
|
|
pass
|
|
|
|
# Prefer the LAST enumerated SDL window (Kivy's window is typically the
|
|
# newest/topmost SDL window); the console is already excluded above.
|
|
return sdl_windows[-1] if sdl_windows else None
|
|
|
|
|
|
def _is_kivy_foreground():
|
|
"""Return True if the Kivy/SDL window is the foreground window.
|
|
|
|
Cheap check (single GetForegroundWindow + class compare) so callers can
|
|
skip the expensive bring-to-front work when the window is already focused.
|
|
"""
|
|
try:
|
|
import win32gui
|
|
fg = win32gui.GetForegroundWindow()
|
|
if not fg:
|
|
return False
|
|
return win32gui.GetClassName(fg) == 'SDL_app'
|
|
except Exception:
|
|
# If win32gui is unavailable, conservatively say "not foreground" so
|
|
# the keeper will call the fallback raise (harmless).
|
|
return False
|
|
|
|
|
|
_BRING_FRONT_LOCK = None # guards concurrent worker-thread bring-to-front calls
|
|
|
|
|
|
def _bring_kivy_to_front(async_ok=True):
|
|
"""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().
|
|
|
|
IMPORTANT (freeze fix): the heavy Win32 work (EnumWindows +
|
|
AttachThreadInput + SetForegroundWindow + SendInput) can block for a long
|
|
time when leaked Chrome/Edge windows fight back, and running it on the
|
|
Kivy main thread wedged the event loop overnight (video stuck, heartbeat
|
|
frozen). When async_ok=True (default, used from the focus keeper), the
|
|
heavy work runs on a background thread so the main thread is never
|
|
blocked; only the cheap fallback raise runs inline.
|
|
|
|
Returns True if the Kivy window is (or is now) the foreground window,
|
|
False otherwise.
|
|
"""
|
|
global _BRING_FRONT_LOCK
|
|
|
|
if not async_ok:
|
|
# Synchronous path: used by explicit transitions (weblink -> media)
|
|
# where the caller has already hidden the overlay and really needs the
|
|
# result now. Still guarded by a lock + timeout-safe call.
|
|
hwnd = None
|
|
try:
|
|
hwnd = _find_kivy_hwnd()
|
|
except Exception:
|
|
hwnd = None
|
|
if hwnd is not None:
|
|
try:
|
|
ok = _bring_hwnd_to_front(hwnd)
|
|
if ok:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from kivy.core.window import Window
|
|
Window.show()
|
|
Window.raise_window()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return _is_kivy_foreground()
|
|
except Exception:
|
|
return False
|
|
|
|
# ── Async path (never blocks the Kivy thread) ──────────────────
|
|
try:
|
|
if _BRING_FRONT_LOCK is None:
|
|
_BRING_FRONT_LOCK = __import__('threading').Lock()
|
|
except Exception:
|
|
_BRING_FRONT_LOCK = None
|
|
|
|
if _BRING_FRONT_LOCK is not None and not _BRING_FRONT_LOCK.acquire(blocking=False):
|
|
# A previous bring-to-front is still running on a worker thread —
|
|
# don't pile up more work on the main thread.
|
|
return False
|
|
|
|
def _work():
|
|
try:
|
|
hwnd = None
|
|
try:
|
|
hwnd = _find_kivy_hwnd()
|
|
except Exception:
|
|
hwnd = None
|
|
if hwnd is not None:
|
|
try:
|
|
_bring_hwnd_to_front(hwnd)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# No SDL window found yet — cheap Kivy raise instead.
|
|
try:
|
|
from kivy.core.window import Window
|
|
Window.raise_window()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if _BRING_FRONT_LOCK is not None:
|
|
try:
|
|
_BRING_FRONT_LOCK.release()
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
t = __import__('threading').Thread(target=_work, daemon=True,
|
|
name='bring-kivy-front-win')
|
|
t.start()
|
|
except Exception:
|
|
if _BRING_FRONT_LOCK is not None:
|
|
try:
|
|
_BRING_FRONT_LOCK.release()
|
|
except Exception:
|
|
pass
|
|
return True # optimistically report; the worker does the work
|
|
|
|
|
|
def _find_chrome_hwnd(proc):
|
|
"""Find the visible top-level HWND of a launched Chrome/Edge process.
|
|
|
|
Returns the HWND if found, otherwise None. Enumerates top-level windows
|
|
owned by the given process and matches Chrome/Edge window classes.
|
|
"""
|
|
if proc is None:
|
|
return None
|
|
try:
|
|
import win32gui
|
|
import win32process
|
|
except Exception:
|
|
return None
|
|
|
|
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/Edge top-level window classes
|
|
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
|
|
return chrome_hwnd
|
|
|
|
|
|
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 _log(msg):
|
|
"""Module-level logger helper (Kivy Logger when available, else print)."""
|
|
try:
|
|
from kivy.logger import Logger
|
|
Logger.info(f"run_win: {msg}")
|
|
except Exception:
|
|
try:
|
|
print(f"[run_win] {msg}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _windows_kill_browsers_on_profile(profile_dir):
|
|
"""Kill every Chrome/Edge process using the given kiosk profile dir.
|
|
|
|
WHY: Chrome/Edge hands off to an existing process when the same
|
|
--user-data-dir is already in use. If a previous weblink leaked a browser
|
|
(e.g. the app was killed while Chrome was up, or the process tree kill
|
|
missed a child), that leaked process keeps the profile lock AND owns the
|
|
visible URL window. The next weblink launch then:
|
|
1. hands the URL to the leaked process,
|
|
2. exits immediately -> the watchdog advances instantly,
|
|
3. and the real browser window is never closed -> windows accumulate
|
|
in the background (observed: 7 leaked msedge.exe processes).
|
|
|
|
This scans running browser processes, matches their command line against
|
|
the profile dir, and taskkills the whole tree so a fresh launch always
|
|
creates (and owns) its own window.
|
|
"""
|
|
if not profile_dir:
|
|
return
|
|
try:
|
|
profile_norm = os.path.normcase(os.path.normpath(profile_dir))
|
|
# Enumerate processes with command lines via WMIC (Windows 8.1+).
|
|
# WMIC is deprecated on Win11 24H2+ but still works; fall back to
|
|
# PowerShell if it is missing.
|
|
rows = []
|
|
try:
|
|
out = subprocess.run(
|
|
['wmic', 'process', 'where',
|
|
"name='chrome.exe' or name='msedge.exe' or name='chromium.exe'",
|
|
'get', 'ProcessId,CommandLine', '/format:csv'],
|
|
capture_output=True, text=True, timeout=15
|
|
)
|
|
for line in out.stdout.splitlines():
|
|
if line.strip() and ',' in line:
|
|
rows.append(line)
|
|
except Exception:
|
|
rows = []
|
|
if not rows:
|
|
# Fallback: PowerShell Get-CimInstance (Win11 24H2+ / no WMIC)
|
|
try:
|
|
ps = (
|
|
"Get-CimInstance Win32_Process -Filter "
|
|
"\"Name='chrome.exe' or Name='msedge.exe' or Name='chromium.exe'\" | "
|
|
"ForEach-Object { \"$($_.ProcessId),$($_.CommandLine)\" }"
|
|
)
|
|
out = subprocess.run(
|
|
['powershell', '-NoProfile', '-Command', ps],
|
|
capture_output=True, text=True, timeout=20
|
|
)
|
|
for line in out.stdout.splitlines():
|
|
if line.strip():
|
|
rows.append(line)
|
|
except Exception:
|
|
rows = []
|
|
|
|
killed = 0
|
|
for row in rows:
|
|
try:
|
|
# CSV: "Node,ProcessId,CommandLine"
|
|
parts = row.split(',', 2)
|
|
if len(parts) < 2:
|
|
continue
|
|
pid_str = parts[1].strip()
|
|
cmd = parts[2] if len(parts) > 2 else ''
|
|
if not pid_str.isdigit():
|
|
continue
|
|
pid = int(pid_str)
|
|
if pid <= 0 or pid == os.getpid():
|
|
continue
|
|
if profile_norm in os.path.normcase(cmd or ''):
|
|
# This browser is using our kiosk profile -> kill its tree.
|
|
try:
|
|
subprocess.run(
|
|
['taskkill', '/F', '/T', '/PID', str(pid)],
|
|
capture_output=True, timeout=5
|
|
)
|
|
killed += 1
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
continue
|
|
if killed:
|
|
_log(f"Killed {killed} leaked browser process(es) using {profile_dir}")
|
|
return killed
|
|
except Exception as e:
|
|
_log(f"_windows_kill_browsers_on_profile error: {e}")
|
|
return 0
|
|
|
|
|
|
def _patch_main():
|
|
"""Patch the main module after import for Windows compatibility."""
|
|
# Logger is only imported lazily inside individual functions; make it
|
|
# available for the patch code at this scope as well. If it cannot be
|
|
# imported (very early startup), fall back to a no-op so the patch logic
|
|
# never crashes on a logging call.
|
|
try:
|
|
from kivy.logger import Logger # noqa: F401
|
|
except Exception:
|
|
class _NullLogger:
|
|
@staticmethod
|
|
def _noop(*args, **kwargs):
|
|
pass
|
|
info = debug = warning = error = critical = exception = staticmethod(_noop)
|
|
Logger = _NullLogger()
|
|
|
|
# ── CRITICAL: Override Linux env vars BEFORE importing main ─────
|
|
# main.py's top-level code sets SDL_VIDEODRIVER=wayland,x11,dummy
|
|
# and other Linux values. We MUST override these before main.py
|
|
# gets imported, otherwise Kivy will initialize with the wrong
|
|
# window provider and crash with SystemExit(1).
|
|
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
|
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
|
os.environ['KIVY_WINDOW'] = 'sdl2'
|
|
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
|
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect
|
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
|
|
|
# Now safe to import main.py — env vars are already Windows-correct
|
|
import main as signage_main
|
|
|
|
# ── Re-apply the fullscreen/window config AFTER main.py is imported ──
|
|
# main.py's top-level code calls Config.set('graphics','fullscreen','0')
|
|
# and window_state='maximized', which OVERRIDES the values run_win.py set
|
|
# before the import. On a DPI-scaled display that left the window at the
|
|
# virtualized size (e.g. 1536x864 on a 1920x1080 monitor), so the Kivy
|
|
# content only covered part of the screen and the desktop showed through
|
|
# the black strip. Re-asserting the config here (after main.py ran, but
|
|
# before App.run() creates the window) makes the SDL window come up at the
|
|
# true fullscreen resolution.
|
|
try:
|
|
from kivy.config import Config as _Config
|
|
_Config.set('graphics', 'fullscreen', '1')
|
|
_Config.set('graphics', 'window_state', 'maximized')
|
|
_Config.set('graphics', 'borderless', '1')
|
|
_Config.set('graphics', 'resizable', '0')
|
|
except Exception:
|
|
pass
|
|
|
|
# Replace signal_screen_activity
|
|
# IMPORTANT: Assign under BOTH the attribute name AND the function's own name.
|
|
# Kivy's WeakMethod stores self.__func__.__name__ (= '_windows_screen_activity')
|
|
# and later does getattr(instance, '_windows_screen_activity'). If we only
|
|
# assign under 'signal_screen_activity', the weakref lookup fails with
|
|
# AttributeError: 'SignagePlayer' object has no attribute '_windows_screen_activity'
|
|
signage_main.SignagePlayer.signal_screen_activity = _windows_screen_activity
|
|
signage_main.SignagePlayer._windows_screen_activity = _windows_screen_activity
|
|
|
|
# ── Windows web-link engines ────────────────────────────────────
|
|
# The player's play_weblink() delegates to WeblinkSession, which owns
|
|
# launch, verified visibility, the interaction watcher and teardown.
|
|
# Windows therefore injects *adapters* (one per browser flavour) instead of
|
|
# overriding play_weblink — that is what removed the old z-order/focus
|
|
# fighting and the leaked-browser accumulation.
|
|
from weblink_session import ChromiumSubprocessAdapter
|
|
|
|
class _WinCefAdapter(ChromiumSubprocessAdapter):
|
|
"""Embedded CEF: renders inside Kivy's window, so no subprocess and
|
|
no z-order battles. Visibility cannot be 'not found' — it either shows
|
|
or raises — so the window check is skipped."""
|
|
|
|
name = 'cef-embedded'
|
|
embedded = True
|
|
|
|
def __init__(self):
|
|
super().__init__(kiosk=False)
|
|
self._browser = None
|
|
self._resize_bound = False
|
|
|
|
@property
|
|
def process(self):
|
|
return None # nothing to kill: CEF lives in-process
|
|
|
|
def launch(self, url, width, height):
|
|
self._browser = _get_cef_browser()
|
|
if self._browser is None:
|
|
return False
|
|
self._bind_resize_once()
|
|
# The page is pumped through the Kivy Clock, so this is safe to
|
|
# call from the main thread.
|
|
return bool(self._browser.show(url))
|
|
|
|
def is_alive(self):
|
|
return self._browser is not None and self._browser.is_showing()
|
|
|
|
def wait_visible(self, timeout):
|
|
"""CEF is embedded: treat 'showing' as visible after a short settle."""
|
|
import time
|
|
deadline = time.monotonic() + min(2.0, max(0.2, timeout))
|
|
while time.monotonic() < deadline:
|
|
if self._browser is not None and self._browser.is_showing():
|
|
# Give the compositor a moment to paint the first frame.
|
|
time.sleep(0.3)
|
|
return True, 'cef-showing'
|
|
time.sleep(0.1)
|
|
return False, 'cef did not show'
|
|
|
|
def on_visible(self):
|
|
trace('win_weblink_CEF_shown')
|
|
|
|
def _bind_resize_once(self):
|
|
"""Bind the resize handler exactly once.
|
|
|
|
The previous implementation rebound a NEW closure on every weblink
|
|
cycle, so Kivy's callback list grew without bound until the app
|
|
slowed down. Binding once removes that leak.
|
|
"""
|
|
if self._resize_bound:
|
|
return
|
|
try:
|
|
from kivy.core.window import Window as KivyWindow
|
|
|
|
def _cef_resize(*args):
|
|
try:
|
|
w, h = KivyWindow.size
|
|
_orig_on_resize = getattr(KivyWindow, '_on_resize', None)
|
|
if _orig_on_resize and getattr(_orig_on_resize, '__name__', '') != '_cef_resize':
|
|
_orig_on_resize(*args)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
browser = _get_cef_browser()
|
|
if browser is not None:
|
|
browser.resize(int(KivyWindow.size[0]), int(KivyWindow.size[1]))
|
|
except Exception:
|
|
pass
|
|
|
|
KivyWindow.bind(size=_cef_resize)
|
|
self._resize_bound = True
|
|
except Exception as exc:
|
|
_log(f"CEF resize bind failed (non-fatal): {exc}")
|
|
|
|
def teardown(self):
|
|
self.cancel_prewarm()
|
|
try:
|
|
if self._browser is not None:
|
|
self._browser.hide()
|
|
except Exception as exc:
|
|
_log(f"CEF hide failed (non-fatal): {exc}")
|
|
|
|
def prewarm(self, url):
|
|
# CEF keeps one browser instance alive; there is nothing to warm.
|
|
pass
|
|
|
|
class _WinWebView2Adapter(ChromiumSubprocessAdapter):
|
|
"""Embedded WebView2 (Edge) — renders INSIDE the Kivy window.
|
|
|
|
This is the preferred Windows engine. Because the page is a child HWND
|
|
of Kivy's own SDL window there is no separate browser process, so none
|
|
of the subprocess problems apply: it cannot open behind the player, it
|
|
cannot be handed off to an existing instance and exit instantly, it
|
|
does not fight for foreground/z-order, and teardown leaves no leaked
|
|
browser behind.
|
|
|
|
``launch`` returns as soon as the (asynchronous) start-up is requested;
|
|
the session's watcher thread then polls :meth:`wait_visible` until the
|
|
page is actually showing.
|
|
"""
|
|
|
|
name = 'webview2-embedded'
|
|
embedded = True
|
|
# WebView2 renders in-window, but unlike raw CEF we CAN prove the page
|
|
# appeared (the controller reports when it is visible). That matters on
|
|
# a closed network: if the weblink host is unreachable the page never
|
|
# paints and the item is skipped instead of showing a blank screen.
|
|
can_verify_visibility = True
|
|
|
|
def __init__(self):
|
|
super().__init__(kiosk=False)
|
|
self._browser = None
|
|
self._resize_bound = False
|
|
|
|
@property
|
|
def process(self):
|
|
return None # in-process: nothing to kill
|
|
|
|
def target_size(self):
|
|
"""Use the real Kivy window size (already DPI-aware)."""
|
|
try:
|
|
from kivy.core.window import Window as KivyWindow
|
|
|
|
width, height = (int(KivyWindow.size[0]), int(KivyWindow.size[1]))
|
|
if width > 0 and height > 0:
|
|
return width, height
|
|
except Exception:
|
|
pass
|
|
return 1920, 1080
|
|
|
|
def launch(self, url, width, height):
|
|
self._browser = _get_webview2_browser()
|
|
if self._browser is None:
|
|
return False
|
|
# A child HWND has no z-order to fight over, but the Kivy window
|
|
# itself must own the foreground or the page looks inert.
|
|
_bring_kivy_to_front(async_ok=False)
|
|
self._bind_resize_once()
|
|
self._browser.resize(width, height)
|
|
return bool(self._browser.show(url))
|
|
|
|
def is_alive(self):
|
|
browser = self._browser
|
|
if browser is None:
|
|
return False
|
|
# NOTE: `is_alive` must be True while the page is still coming up.
|
|
# WebView2 creates its environment and controller asynchronously, so
|
|
# a controller that does not exist yet is NOT a dead browser —
|
|
# treating it as one made the first weblink be skipped instantly.
|
|
return browser.is_alive()
|
|
|
|
def wait_visible(self, timeout):
|
|
"""Wait for the page to actually load, on the caller's (watcher) thread.
|
|
|
|
Two things must hold: the controller must be showing, AND the
|
|
navigation must have completed successfully. The second condition is
|
|
what makes an unreachable host (normal on a closed network) skip the
|
|
item instead of displaying Chromium's error page for the full slot.
|
|
"""
|
|
import time
|
|
|
|
browser = self._browser
|
|
if browser is None:
|
|
return False, 'no browser'
|
|
deadline = time.monotonic() + max(1.0, float(timeout))
|
|
while time.monotonic() < deadline:
|
|
if browser.failed_reason:
|
|
return False, browser.failed_reason
|
|
nav = browser.navigation_succeeded()
|
|
if browser.is_showing() and nav is not None:
|
|
if not nav:
|
|
detail = browser.navigation_status() or 'navigation failed'
|
|
return False, f'page failed to load ({detail})'
|
|
# Let the compositor paint the first frame before any
|
|
# masking overlay is hidden / Kivy is restored.
|
|
time.sleep(0.3)
|
|
return True, 'webview2-loaded'
|
|
time.sleep(0.1)
|
|
if browser.is_showing() and browser.navigation_succeeded() is None:
|
|
# Controller is up but navigation never reported within the
|
|
# timeout. Treat as visible rather than skipping a page that is
|
|
# merely slow to confirm.
|
|
return True, 'webview2-showing-unconfirmed'
|
|
reason = browser.failed_reason or 'webview2 did not show'
|
|
return False, reason
|
|
|
|
def on_visible(self):
|
|
trace('win_weblink_webview2_shown')
|
|
|
|
def on_launch_failed(self):
|
|
# Never leave a half-built controller (or a hidden child window)
|
|
# behind when start-up fails.
|
|
browser, self._browser = self._browser, None
|
|
if browser is not None:
|
|
try:
|
|
browser.shutdown()
|
|
except Exception:
|
|
pass
|
|
_get_webview2_browser(force_new=True)
|
|
|
|
def _bind_resize_once(self):
|
|
"""Keep the page matched to the window, bound exactly once.
|
|
|
|
Rebinding per cycle was the unbounded-callback leak fixed for CEF;
|
|
the same discipline applies here.
|
|
"""
|
|
if self._resize_bound:
|
|
return
|
|
try:
|
|
from kivy.core.window import Window as KivyWindow
|
|
|
|
def _wv2_resize(*_args):
|
|
try:
|
|
browser = self._browser
|
|
if browser is not None and browser.is_showing():
|
|
browser.resize(
|
|
int(KivyWindow.size[0]), int(KivyWindow.size[1])
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
KivyWindow.bind(size=_wv2_resize)
|
|
self._resize_bound = True
|
|
except Exception as exc:
|
|
_log(f"WebView2 resize bind failed (non-fatal): {exc}")
|
|
|
|
def teardown(self):
|
|
"""Hide the page (keep the controller for a fast next show).
|
|
|
|
Hiding — not disposing — is deliberate: the controller stays alive
|
|
so the next weblink paints immediately, and because it is a child
|
|
window there is no leaked process to reap.
|
|
"""
|
|
browser = self._browser
|
|
if browser is not None:
|
|
try:
|
|
browser.hide()
|
|
except Exception as exc:
|
|
_log(f"WebView2 hide failed (non-fatal): {exc}")
|
|
# Restore Kivy as the visible surface again.
|
|
_bring_kivy_to_front(async_ok=False)
|
|
|
|
def prewarm(self, url):
|
|
# The environment/controller are already warm after the first use;
|
|
# pre-navigating would claim the page before its slot.
|
|
pass
|
|
|
|
class _WinChromeAdapter(ChromiumSubprocessAdapter):
|
|
"""Chrome/Edge kiosk subprocess with the Windows visibility check.
|
|
|
|
Verifying the real HWND (rather than just the process) is what stops a
|
|
blank screen when the page never paints.
|
|
"""
|
|
|
|
name = 'chrome-subprocess'
|
|
embedded = False
|
|
|
|
def __init__(self, browser_path):
|
|
super().__init__(browser_path=browser_path, kiosk=True)
|
|
self._profile_dir = None
|
|
|
|
def on_before_launch(self, url, width, height):
|
|
_Win32Overlay.show()
|
|
trace('win_overlay_shown')
|
|
|
|
def on_launch_failed(self):
|
|
_Win32Overlay.hide()
|
|
|
|
def extra_launch_args(self):
|
|
"""Dedicated profile + kiosk flags for the Windows browser.
|
|
|
|
``--user-data-dir`` is mandatory: without it Chrome/Edge hands the
|
|
URL to an already-running instance, our launched process exits in
|
|
~2s and the weblink never displays (the adapter's `wait_visible`
|
|
then sees the process die and the item is skipped). It also gives
|
|
us a top-level window we can enumerate, raise and taskkill without
|
|
touching the operator's own browser profile.
|
|
"""
|
|
args = []
|
|
if self._profile_dir:
|
|
args.append('--user-data-dir=' + self._profile_dir)
|
|
if self._kiosk:
|
|
# The base launch() already adds --start-fullscreen /
|
|
# --start-maximized; --kiosk upgrades that to a true kiosk
|
|
# window (no browser UI, locks to the screen).
|
|
args.append('--kiosk')
|
|
return args
|
|
|
|
def launch(self, url, width, height):
|
|
# A dedicated --user-data-dir is mandatory: without it Chrome hands
|
|
# the URL to an existing process, the launched process exits
|
|
# immediately and the weblink never displays.
|
|
self._profile_dir = os.path.join(
|
|
os.environ.get('KIWY_DATA_DIR', os.getcwd()), '.kiosk-profile'
|
|
)
|
|
try:
|
|
os.makedirs(self._profile_dir, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Kill any leaked browser still holding this profile's lock BEFORE
|
|
# launching, otherwise the new launch hands off and exits.
|
|
_windows_kill_browsers_on_profile(self._profile_dir)
|
|
|
|
return super().launch(url, width, height)
|
|
|
|
def wait_visible(self, timeout):
|
|
"""Poll for the real Chrome/Edge window, then hide the overlay."""
|
|
import time
|
|
proc = self.process
|
|
if proc is None:
|
|
return False, 'no process'
|
|
deadline = time.monotonic() + max(1.0, float(timeout))
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
# Exited early: either a hand-off or a crash. The process
|
|
# tree kill on the next cycle cleans up any leaked window.
|
|
return False, f'browser exited early (rc={proc.returncode})'
|
|
hwnd = _find_chrome_hwnd(proc)
|
|
if hwnd is not None:
|
|
_Win32Overlay.hide()
|
|
_bring_hwnd_to_front(hwnd)
|
|
return True, f'hwnd={hwnd}'
|
|
time.sleep(0.1)
|
|
return False, 'browser window never appeared'
|
|
|
|
def teardown(self):
|
|
"""Kill the process tree, then restore Kivy — in the safe order."""
|
|
proc, self._proc = self._proc, None
|
|
if proc is not None and proc.poll() is None:
|
|
trace('win_killing_chrome', pid=proc.pid)
|
|
_windows_kill_process_tree(proc)
|
|
# ORDER MATTERS: hide the fullscreen overlay BEFORE raising Kivy.
|
|
# If the topmost overlay is destroyed after Kivy is raised, Windows
|
|
# hands foreground to Explorer instead of our window — the
|
|
# "player runs but stays in the background" bug.
|
|
_Win32Overlay.hide()
|
|
_bring_kivy_to_front()
|
|
try:
|
|
_reassert_kivy_fullscreen(signage_main.SignagePlayer)
|
|
except Exception:
|
|
pass
|
|
|
|
def prewarm(self, url):
|
|
# Disabled on Windows: an off-screen Chrome claims the audio device,
|
|
# spawns GPU processes and adds a duplicate taskbar entry.
|
|
pass
|
|
|
|
def _windows_weblink_adapter_factory(player):
|
|
"""Choose the Windows web-link engines, best first.
|
|
|
|
Order matters:
|
|
|
|
1. **WebView2 (embedded)** — a child HWND of the Kivy window. It cannot
|
|
open behind the player, cannot be handed off to an existing browser
|
|
and exit instantly, does not fight for z-order/foreground, and
|
|
leaves no leaked process behind. This is the preferred engine.
|
|
2. **CEF (embedded)** — same in-window model, but cefpython3 has no
|
|
wheels past Python 3.9 so it is dormant on this build.
|
|
3. **Chrome/Edge subprocess** — last resort only. Kept so a machine
|
|
without the WebView2 runtime still shows weblinks.
|
|
"""
|
|
adapters = []
|
|
if _get_webview2_browser() is not None:
|
|
adapters.append(_WinWebView2Adapter())
|
|
if _get_cef_browser() is not None:
|
|
adapters.append(_WinCefAdapter())
|
|
browser = _windows_find_browser()
|
|
if browser:
|
|
adapters.append(_WinChromeAdapter(browser))
|
|
if not adapters:
|
|
_log("WebView2/CEF unavailable and no Chrome/Edge found — "
|
|
"web links will be skipped")
|
|
return adapters
|
|
|
|
signage_main.SignagePlayer.weblink_adapter_factory = staticmethod(
|
|
_windows_weblink_adapter_factory
|
|
)
|
|
|
|
# Replace weblink handling
|
|
# ── Give play_video a hook to re-assert the Kivy window to the front ──
|
|
# The main module calls `self._bring_kivy_to_front_win` (if present) right
|
|
# after adding a video widget, so the image -> video transition never lets
|
|
# the host desktop steal the foreground. It also exposes a CHEAP foreground
|
|
# check so the focus keeper can skip the expensive bring-to-front work
|
|
# whenever the window is already focused.
|
|
signage_main.SignagePlayer._bring_kivy_to_front_win = staticmethod(
|
|
lambda: _bring_kivy_to_front()
|
|
)
|
|
signage_main.SignagePlayer._is_foreground_win = staticmethod(
|
|
lambda: _is_kivy_foreground()
|
|
)
|
|
|
|
# NOTE: the old Windows overrides of play_weblink / _start_inactivity_watchdog
|
|
# / _kill_weblink_after_frame / play_current_media / _prewarm_weblink have
|
|
# been REMOVED. WeblinkSession (src/weblink_session.py) is now the single
|
|
# owner of launch, verified visibility, the interaction watcher and
|
|
# teardown; the Windows-specific behaviour lives in the adapters injected
|
|
# by `_windows_weblink_adapter_factory` above.
|
|
|
|
|
|
# Patch cleanup of temp auth file (was using /tmp/)
|
|
_original_connection_test = signage_main.SettingsPopup.test_connection
|
|
|
|
def _windows_test_connection(self):
|
|
"""Test connection with Windows-safe temp file path."""
|
|
import tempfile as _tf
|
|
import os as _os
|
|
from player_auth import PlayerAuth
|
|
import re
|
|
import threading
|
|
from kivy.clock import Clock
|
|
|
|
self.ids.connection_status.text = 'Testing connection...'
|
|
self.ids.connection_status.color = (1, 0.7, 0, 1)
|
|
|
|
def run_test():
|
|
try:
|
|
server_ip = self.ids.server_input.text.strip()
|
|
screen_name = self.ids.screen_input.text.strip()
|
|
quickconnect = self.ids.quickconnect_input.text.strip()
|
|
port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
|
|
use_https = self.player.config.get('use_https', True)
|
|
verify_ssl = self.player.config.get('verify_ssl', True)
|
|
|
|
if not all([server_ip, screen_name, quickconnect]):
|
|
Clock.schedule_once(lambda dt: self.update_connection_status('Error: Fill all fields', False))
|
|
return
|
|
|
|
if server_ip.startswith('http://') or server_ip.startswith('https://'):
|
|
server_url = server_ip
|
|
if ':' not in server_ip.replace('https://', '').replace('http://', ''):
|
|
if port and port not in ('443', '80'):
|
|
server_url = f"{server_ip}:{port}"
|
|
else:
|
|
protocol = "https" if use_https else "http"
|
|
if ':' in server_ip:
|
|
server_url = f"{protocol}://{server_ip}"
|
|
else:
|
|
server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}"
|
|
|
|
# Use Windows temp path
|
|
temp_file = _os.path.join(_tf.gettempdir(), 'temp_auth_test.json')
|
|
auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl)
|
|
success, error = auth.authenticate(server_url=server_url, hostname=screen_name, quickconnect_code=quickconnect)
|
|
|
|
try:
|
|
if _os.path.exists(temp_file):
|
|
_os.remove(temp_file)
|
|
except Exception:
|
|
pass
|
|
|
|
if success:
|
|
player_name = auth.get_player_name()
|
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✓ Connected: {player_name}', True))
|
|
else:
|
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Failed: {error}', False))
|
|
|
|
except Exception as e:
|
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Error: {str(e)}', False))
|
|
|
|
threading.Thread(target=run_test, daemon=True).start()
|
|
|
|
signage_main.SettingsPopup.test_connection = _windows_test_connection
|
|
|
|
# ── Keep player auth OUT of the bundle ───────────────────────────
|
|
# `player_auth.json` used to be a tracked file inside src/, so PyInstaller
|
|
# bundled it and, because the frozen app runs with cwd = _internal/, the
|
|
# player loaded it as its live auth state. A stale snapshot therefore made
|
|
# a fresh build boot "already authenticated" against an old server and play
|
|
# an outdated playlist.
|
|
#
|
|
# Fix: never read a relative auth path from the bundle. Any relative
|
|
# 'player_auth.json' is redirected to the data dir next to the .exe, which
|
|
# is the single source of truth for this install.
|
|
import player_auth as _player_auth_module
|
|
|
|
_bundled_auth = os.path.join(
|
|
getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))),
|
|
'player_auth.json',
|
|
)
|
|
_local_auth = os.path.join(DATA_DIR, 'player_auth.json')
|
|
|
|
# If an older build left the bundled snapshot next to the exe, drop it:
|
|
# it names another server and would be trusted on the next start-up.
|
|
try:
|
|
if os.path.isfile(_local_auth):
|
|
import json as _json
|
|
|
|
with open(_local_auth, 'r') as _f:
|
|
_existing = _json.load(_f)
|
|
_existing_url = str(_existing.get('server_url') or '')
|
|
_wanted_ip = str(os.environ.get('KIWY_SERVER_IP') or '')
|
|
if _wanted_ip and _wanted_ip not in _existing_url:
|
|
Logger.warning(
|
|
"SignagePlayer: stored auth points at %s but the configured "
|
|
"server is %s - clearing it so the player re-authenticates",
|
|
_existing_url or '(none)', _wanted_ip,
|
|
)
|
|
os.remove(_local_auth)
|
|
except Exception as _exc:
|
|
Logger.debug(f"SignagePlayer: auth pre-check skipped: {_exc}")
|
|
|
|
_original_auth_init = _player_auth_module.PlayerAuth.__init__
|
|
|
|
def _windows_auth_init(self, config_file='player_auth.json',
|
|
use_https=True, verify_ssl=True):
|
|
"""Force the auth file to live in the data dir next to the .exe."""
|
|
try:
|
|
if not os.path.isabs(config_file):
|
|
config_file = os.path.join(DATA_DIR, os.path.basename(config_file))
|
|
except Exception:
|
|
config_file = _local_auth
|
|
_original_auth_init(self, config_file, use_https=use_https, verify_ssl=verify_ssl)
|
|
|
|
_player_auth_module.PlayerAuth.__init__ = _windows_auth_init
|
|
Logger.info(f"SignagePlayer: player auth file -> {_local_auth}")
|
|
|
|
# `get_playlists_v2` caches a global auth instance created with the default
|
|
# relative path; make sure its cache is empty so the redirect above applies.
|
|
try:
|
|
import get_playlists_v2 as _gp
|
|
|
|
if _gp._auth_instance is not None:
|
|
_gp._auth_instance = None
|
|
except Exception as _exc:
|
|
Logger.debug(f"SignagePlayer: could not reset auth cache: {_exc}")
|
|
|
|
# `reset_player_auth` deleted the file next to main.py (i.e. inside the
|
|
# bundle, which is read-only and not what we load). Point it at the real
|
|
# auth file so the "Reset auth" button actually works.
|
|
def _windows_reset_player_auth(self):
|
|
try:
|
|
if os.path.exists(_local_auth):
|
|
os.remove(_local_auth)
|
|
Logger.info(f"SettingsPopup: Deleted authentication file: {_local_auth}")
|
|
self._show_temp_message(
|
|
'✓ Authentication reset - will reauthenticate on restart', (0, 1, 0, 1)
|
|
)
|
|
except Exception as exc:
|
|
Logger.error(f"SettingsPopup: Failed to reset auth: {exc}")
|
|
|
|
signage_main.SettingsPopup.reset_player_auth = _windows_reset_player_auth
|
|
|
|
# ── Patch apply_kiosk_mode for Windows ──────────────────────────
|
|
# Wrap the base implementation (exit_on_escape + close guard + Ctrl+C)
|
|
# and add the low-level keyboard hook that swallows Alt+F4 / Alt+Tab /
|
|
# Win / Ctrl+Esc while production mode is active.
|
|
_base_apply = signage_main.SignagePlayer.apply_kiosk_mode
|
|
|
|
def _windows_apply_kiosk_mode_patch(self, enabled):
|
|
"""Windows kiosk lockdown = base logic + keyboard hook."""
|
|
_base_apply(self, enabled)
|
|
if enabled:
|
|
_install_kb_lockdown()
|
|
Logger.info(
|
|
"SignagePlayer: Windows keyboard lockdown ACTIVE "
|
|
"(Alt+F4/Alt+Tab/Win/Ctrl+Esc swallowed)"
|
|
)
|
|
else:
|
|
_uninstall_kb_lockdown()
|
|
Logger.info("SignagePlayer: Windows keyboard lockdown DISABLED")
|
|
|
|
signage_main.SignagePlayer.apply_kiosk_mode = _windows_apply_kiosk_mode_patch
|
|
|
|
# ── Patch CardReader for Windows ────────────────────────────────
|
|
# The Linux CardReader uses evdev (/dev/input/event*), which does not
|
|
# exist on Windows. Replace the class reference so that
|
|
# SignagePlayer.show_edit_interface() uses the Windows implementation
|
|
# (Raw Input API + LL-hook fallback) instead.
|
|
signage_main.CardReader = WindowsCardReader
|
|
Logger.info(
|
|
"SignagePlayer: CardReader patched -> WindowsCardReader "
|
|
"(Raw Input API)"
|
|
)
|
|
|
|
# ── Shut the card reader pump down on app exit ─────────────────
|
|
_original_on_stop = signage_main.SignagePlayerApp.on_stop
|
|
|
|
def _windows_on_stop(self):
|
|
try:
|
|
root = getattr(self, 'root', None)
|
|
if root is not None:
|
|
cr = getattr(root, 'card_reader', None)
|
|
if cr is not None and hasattr(cr, 'shutdown'):
|
|
cr.shutdown()
|
|
Logger.info("SignagePlayer: Windows card reader shut down")
|
|
except Exception as e:
|
|
Logger.debug(f"SignagePlayer: card reader shutdown error: {e}")
|
|
# Tear down the embedded web engine. Because it is a child HWND (not a
|
|
# subprocess) this is what stops it surviving the player on exit.
|
|
try:
|
|
_get_webview2_browser(force_new=True)
|
|
Logger.info("SignagePlayer: WebView2 browser shut down")
|
|
except Exception as e:
|
|
Logger.debug(f"SignagePlayer: WebView2 shutdown error: {e}")
|
|
_original_on_stop(self)
|
|
|
|
signage_main.SignagePlayerApp.on_stop = _windows_on_stop
|
|
|
|
# ── Force the Kivy window to cover the full physical monitor ──
|
|
# Kivy's fullscreen config can leave the SDL window at the DPI-virtualized
|
|
# size, showing a black strip + desktop bleed-through. Once the app starts
|
|
# we resize the window to the true monitor bounds and keep re-asserting it
|
|
# for the first few seconds (the SDL window may not exist immediately).
|
|
_original_on_start = signage_main.SignagePlayerApp.on_start
|
|
|
|
def _windows_on_start(self):
|
|
_original_on_start(self)
|
|
try:
|
|
from kivy.clock import Clock
|
|
|
|
# Continuous sizing guardian: every 2s, if the SDL window is not at
|
|
# the true monitor bounds (e.g. Chrome left it DPI-virtualized after
|
|
# a weblink), resize it back and re-sync the Kivy layout. This
|
|
# self-heals the "black strip + wrong size after a weblink" bug
|
|
# without a heavy constant SetWindowPos loop (the helper no-ops
|
|
# when the size already matches).
|
|
def _guard(dt):
|
|
try:
|
|
_reassert_kivy_fullscreen(self.root)
|
|
except Exception:
|
|
pass
|
|
|
|
Clock.schedule_interval(_guard, 2.0)
|
|
except Exception as e:
|
|
_log(f"fullscreen guard start error: {e}")
|
|
|
|
signage_main.SignagePlayerApp.on_start = _windows_on_start
|
|
|
|
return signage_main
|
|
|
|
|
|
# =====================================================================
|
|
# 4. Adjust path so we can import the src modules
|
|
# =====================================================================
|
|
# When run from PyInstaller .exe: the runtime hook inserts paths.
|
|
# When run as plain python, we add src/ relative to this file.
|
|
_script_dir = Path(__file__).resolve().parent
|
|
_project_root = _script_dir.parent
|
|
_src_dir = _project_root / 'src'
|
|
|
|
for p in [str(_src_dir), str(_project_root)]:
|
|
if p not in sys.path:
|
|
sys.path.insert(0, p)
|
|
|
|
# =====================================================================
|
|
# 5. Determine the local data directory (next to the executable)
|
|
# =====================================================================
|
|
# The pyi_runtime_hook.py (when packaged) sets KIWY_DATA_DIR.
|
|
# When running in dev mode from python, use the project root.
|
|
# The executable will create its own local folders for playlist,
|
|
# media, config, and logs where the executable is launched.
|
|
DATA_DIR = os.environ.get('KIWY_DATA_DIR', str(_project_root))
|
|
|
|
# Create local data folders NEXT TO the executable
|
|
os.makedirs(os.path.join(DATA_DIR, 'config', 'resources'), exist_ok=True)
|
|
os.makedirs(os.path.join(DATA_DIR, 'media'), exist_ok=True)
|
|
os.makedirs(os.path.join(DATA_DIR, 'media', 'edited_media'), exist_ok=True)
|
|
os.makedirs(os.path.join(DATA_DIR, 'playlists'), exist_ok=True)
|
|
os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True)
|
|
os.makedirs(os.path.join(DATA_DIR, 'config', 'certs'), exist_ok=True)
|
|
|
|
# =====================================================================
|
|
# 6. Set Kivy config BEFORE importing Kivy
|
|
# =====================================================================
|
|
os.environ['KIVY_NO_FILELOG'] = '1' # Avoid file logging issues on Windows
|
|
os.environ['KIVY_HOME'] = os.path.join(DATA_DIR, '.kivy')
|
|
|
|
from kivy.config import Config
|
|
Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard
|
|
Config.set('graphics', 'fullscreen', '0')
|
|
Config.set('graphics', 'window_state', 'maximized')
|
|
Config.set('graphics', 'multisampling', '0')
|
|
Config.set('graphics', 'fast_rgba', '1')
|
|
Config.set('kivy', 'log_level', 'warning')
|
|
|
|
# =====================================================================
|
|
# 7. Patch the main module, then run the app
|
|
# =====================================================================
|
|
if __name__ == '__main__':
|
|
try:
|
|
# Write a startup marker so we know the .exe at least launched
|
|
try:
|
|
os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True)
|
|
marker = os.path.join(DATA_DIR, 'logs', 'startup_marker.txt')
|
|
with open(marker, 'w') as f:
|
|
f.write(f"run_win.py started at {__import__('time').time()}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
# Show the persistent black backdrop BEFORE Kivy initializes so the
|
|
# host desktop is never visible during startup or browser transitions.
|
|
_Win32Backdrop.show()
|
|
|
|
# Keep the display and system awake and disable the screensaver/lock
|
|
# screen from the very start (before Kivy even initializes), so the
|
|
# host never blanks, sleeps or locks while the player is up.
|
|
_disable_windows_screensaver()
|
|
|
|
# Apply all Windows patches before launching
|
|
try:
|
|
patched_main = _patch_main()
|
|
except Exception as e:
|
|
# Catch early import errors (pre-Logger) to a file
|
|
import traceback
|
|
try:
|
|
err_log = os.path.join(DATA_DIR, 'logs', 'startup_error.log')
|
|
with open(err_log, 'w') as f:
|
|
f.write(f"Error in _patch_main(): {e}\n")
|
|
traceback.print_exc(file=f)
|
|
except Exception:
|
|
pass
|
|
raise # Re-raise so console shows it too
|
|
|
|
from kivy.logger import Logger
|
|
Logger.info("=" * 80)
|
|
Logger.info("Kiwy Signage Player - Windows Edition")
|
|
Logger.info(f"Python: {sys.version}")
|
|
Logger.info(f"Platform: {platform.platform()}")
|
|
Logger.info(f"Data directory: {DATA_DIR}")
|
|
Logger.info("=" * 80)
|
|
|
|
# ── Ensure the WebView2 Runtime is present ───────────────────
|
|
# The bundled SDK is only an API surface; the Runtime holds the actual
|
|
# engine. Windows 11 and most Windows 10 machines already have it, but
|
|
# when it is missing we install it silently (per-user, so no UAC prompt
|
|
# appears on the signage display). Runs in the background so start-up
|
|
# is not blocked.
|
|
_ensure_webview2_runtime_async()
|
|
|
|
# Patch base_dir in SignagePlayer instances to point to local data folder
|
|
_original_init = patched_main.SignagePlayer.__init__
|
|
|
|
def _patched_init(self, **kwargs):
|
|
"""Override SignagePlayer.__init__ to use local data folders.
|
|
|
|
Creates all necessary folders (config, media, playlists, logs)
|
|
in the same directory where the executable is launched.
|
|
"""
|
|
# Call parent Widget.__init__
|
|
_original_init(self, **kwargs)
|
|
# Now override ALL paths to point to local data directory
|
|
# (where the .exe is located)
|
|
self.base_dir = DATA_DIR
|
|
self.config_dir = os.path.join(DATA_DIR, 'config')
|
|
self.media_dir = os.path.join(DATA_DIR, 'media')
|
|
self.playlists_dir = os.path.join(DATA_DIR, 'playlists')
|
|
self.config_file = os.path.join(self.config_dir, 'app_config.json')
|
|
self.resources_path = os.path.join(self.config_dir, 'resources')
|
|
self.heartbeat_file = os.path.join(DATA_DIR, '.player_heartbeat')
|
|
# Ensure all required folders exist locally
|
|
for directory in [
|
|
self.config_dir,
|
|
self.resources_path,
|
|
os.path.join(self.media_dir, 'edited_media'),
|
|
self.playlists_dir,
|
|
os.path.join(DATA_DIR, 'logs'),
|
|
os.path.join(DATA_DIR, 'config', 'certs'),
|
|
]:
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
patched_main.SignagePlayer.__init__ = _patched_init
|
|
|
|
# Patch SSLManager cert directory to use local data folder
|
|
# ssl_utils is imported by player_auth.py and get_playlists_v2.py, not main.py
|
|
import ssl_utils
|
|
ssl_utils.SSLManager.CERT_DIR = os.path.join(DATA_DIR, 'config', 'certs')
|
|
ssl_utils.SSLManager.CERT_FILE = os.path.join(
|
|
ssl_utils.SSLManager.CERT_DIR, 'server_cert.pem'
|
|
)
|
|
ssl_utils.SSLManager.CERT_INFO_FILE = os.path.join(
|
|
ssl_utils.SSLManager.CERT_DIR, 'cert_info.json'
|
|
)
|
|
|
|
# Run the app
|
|
try:
|
|
app = patched_main.SignagePlayerApp()
|
|
app.run()
|
|
except KeyboardInterrupt:
|
|
Logger.info("Application stopped by user (Ctrl+C)")
|
|
except SystemExit as _se:
|
|
Logger.critical(f"Kivy SystemExit (likely window provider missing): {_se}")
|
|
try:
|
|
crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log')
|
|
with open(crash_log, 'w') as f:
|
|
f.write(f"Kivy SystemExit: {_se}\n")
|
|
f.write("This usually means Kivy could not find a window provider on this system.\n")
|
|
except Exception:
|
|
pass
|
|
_show_error_box(
|
|
"Kiwy Signage Player - Kivy Error",
|
|
f"Kivy exited: {_se}\n\n"
|
|
"This usually means Kivy could not create a window.\n"
|
|
"Check your GPU drivers and DirectX installation.\n\n"
|
|
"See logs/crash.log for details."
|
|
)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
Logger.critical(f"Fatal error: {e}")
|
|
Logger.exception("Full traceback:")
|
|
# Also write to a crash log next to the executable
|
|
try:
|
|
import traceback
|
|
crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log')
|
|
with open(crash_log, 'w') as f:
|
|
f.write(f"Fatal error: {e}\n")
|
|
traceback.print_exc(file=f)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
finally:
|
|
Logger.info("Application shutdown complete")
|
|
_restore_windows_screensaver() # restore original screensaver state
|
|
_Win32Backdrop.hide() # remove backdrop on clean exit
|
|
except BaseException as _top_e:
|
|
# Catch any error BEFORE Logger is available (including SystemExit)
|
|
import traceback as _tb
|
|
_trace = _tb.format_exc()
|
|
try:
|
|
_crash_log = os.path.join(DATA_DIR, 'logs', 'fatal_crash.log')
|
|
with open(_crash_log, 'w') as _f:
|
|
_f.write(f"FATAL (pre-Logger): {_top_e}\n")
|
|
_f.write(_trace)
|
|
except Exception:
|
|
pass
|
|
_show_error_box(
|
|
"Kiwy Signage Player - Startup Error",
|
|
f"{_top_e}\n\nSee logs/fatal_crash.log for details."
|
|
)
|
|
raise # Re-raise so .exe still shows the error
|