6dc79828bc
src/main.py: - Delegate web-link playback to WeblinkSession (get_weblink_session), with on_finished / on_failed callbacks driving the transition. - Remove the in-file Chromium subprocess launching, the /dev/input watchdog and the pre-warm implementation; keep thin deprecated shims for platform code. - Add _item_is_weblink(), which also accepts type aliases and the "no file extension + http(s) url" shape so a slightly different server payload is not treated as a missing media file. - Replace the 1.0s wall-clock advance guard with a generation token (next_media's _token plus _schedule_advance). The old window silently dropped deliberate fast transitions such as weblink -> weblink; the token still discards stale/duplicated callbacks. - toggle_pause is now a no-op while a web link is active: a web link is an interactive surface, so pause/play does not apply to it and can no longer cut a viewer's session short. Pause still works for images and videos. - Preload/prewarm the next item through the session. - _get_browser_target_size uses the real window size instead of hardcoding a 1920x1080 fallback. windows/run_win.py: - Replace the Windows play_weblink override, watchdog, kill_weblink_after_frame, play_current_media wrapper and prewarm override with adapter injection via weblink_adapter_factory. - _WinCefAdapter: embedded CEF, preferred (no subprocess, no z-order fights). Binds the Kivy resize handler once instead of rebinding a new closure every weblink cycle, which grew the callback list without bound. - _WinChromeAdapter: Chrome/Edge subprocess with a real HWND visibility check, so a hand-off or a page that never paints is detected instead of leaving a black screen. Teardown keeps the required order (hide overlay, then raise Kivy) to avoid handing foreground to Explorer. - Delete the now-dead _hide_overlay_when_chrome_ready and _bring_chrome_to_front. The former leaked a Clock.schedule_interval on every weblink cycle.
1932 lines
75 KiB
Python
1932 lines
75 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
|
|
|
|
|
|
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 _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 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.
|
|
|
|
CEF (embedded) is preferred when available because it cannot fight for
|
|
z-order or foreground; the Chrome/Edge subprocess is the fallback.
|
|
"""
|
|
adapters = []
|
|
if _get_cef_browser() is not None:
|
|
adapters.append(_WinCefAdapter())
|
|
browser = _windows_find_browser()
|
|
if browser:
|
|
adapters.append(_WinChromeAdapter(browser))
|
|
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
|
|
|
|
# ── 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}")
|
|
_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)
|
|
|
|
# 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
|