Files
Kiwy-Signage/windows/run_win.py
T
ske087 31ad592e98 Fix Windows player: kiosk lockdown, robust video transitions, keep-awake
- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C
  ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows)
- Robust video playback: async (non-blocking) ffpyplayer teardown,
  video progress watchdog (advance at true clip end), EOS re-entrancy
  guard, stale-advance guard, focus keeper for foreground retention
- Resume playback timer after Settings/exit popups close
- Windows keep-awake: SetThreadExecutionState + disable screensaver/
  lock screen (restored on exit)
- Always-on playback_trace.log for diagnosing transitions
- exe metadata: app_icon.ico + version_info.txt (publisher identity)
2026-08-04 15:57:33 +03:00

1470 lines
56 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 _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 until Chrome's window is detected
(`_hide_overlay_when_chrome_ready`) 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 _bring_hwnd_to_front(hwnd):
"""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
attach our calling thread (and the target window's thread) to the current
foreground window's input thread before calling SetForegroundWindow.
"""
if not hwnd:
return
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
# If minimized, restore first so the window can actually be shown.
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, _SW_RESTORE)
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
user32.ShowWindow(hwnd, _SW_SHOWNORMAL)
user32.BringWindowToTop(hwnd)
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)
def _find_kivy_hwnd():
"""Return the HWND of the Kivy/SDL window, or None."""
try:
import win32gui
except Exception:
return None
hwnd_list = []
def _enum_cb(hwnd, _):
try:
cls = win32gui.GetClassName(hwnd)
title = win32gui.GetWindowText(hwnd)
except Exception:
return
if cls == "SDL_app" or "Kiwy" in title or "Signage" in title:
hwnd_list.append(hwnd)
try:
win32gui.EnumWindows(_enum_cb, None)
except Exception:
pass
return hwnd_list[-1] if hwnd_list 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
def _bring_kivy_to_front():
"""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().
"""
try:
hwnd = _find_kivy_hwnd()
if hwnd is None:
return
_bring_hwnd_to_front(hwnd)
except Exception:
# Fallback to Kivy's built-in raise
try:
from kivy.core.window import Window
Window.show()
Window.raise_window()
except Exception:
pass
def _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 _bring_chrome_to_front(proc):
"""Find the top-level window of a launched Chrome/Edge process and bring
it to the foreground (so the weblink is actually visible over Kivy)."""
chrome_hwnd = _find_chrome_hwnd(proc)
if chrome_hwnd is not None:
_bring_hwnd_to_front(chrome_hwnd)
else:
# Give the browser a moment to create its window, then retry once.
import time
time.sleep(0.3)
chrome_hwnd = _find_chrome_hwnd(proc)
if chrome_hwnd is not None:
_bring_hwnd_to_front(chrome_hwnd)
def _hide_overlay_when_chrome_ready(proc, timeout=5.0, poll_interval=0.1):
"""Hide the black overlay only once the weblink browser is on screen.
Polls for the Chrome/Edge window (main thread via Kivy Clock). The overlay
stays up until the browser window is detected and brought to the front —
this guarantees the host desktop is never exposed while Chrome is still
starting (cold start / slow disk / GPU). If Chrome never appears within
`timeout` seconds, the overlay is hidden anyway and Kivy is raised.
"""
from kivy.clock import Clock
from kivy.logger import Logger
_elapsed = [0.0]
def _poll(dt):
_elapsed[0] += dt
hwnd = _find_chrome_hwnd(proc)
if hwnd is not None:
_Win32Overlay.hide()
_bring_hwnd_to_front(hwnd)
Logger.info(
f"SignagePlayer: Browser window detected ({hwnd}) — overlay hidden"
)
return False # stop polling
if _elapsed[0] >= timeout:
Logger.warning(
"SignagePlayer: Chrome window not detected in "
f"{timeout:.0f}s — hiding overlay and raising Kivy"
)
_Win32Overlay.hide()
_bring_kivy_to_front()
return False # stop polling
return True # keep polling
# First check immediately (Chrome may already be up), then poll.
hwnd = _find_chrome_hwnd(proc)
if hwnd is not None:
_Win32Overlay.hide()
_bring_hwnd_to_front(hwnd)
return
Clock.schedule_interval(_poll, poll_interval)
def _windows_kill_process_tree(proc):
"""Kill a process AND all its children using taskkill.
Chrome/Edge spawns many child processes (GPU, renderer, network,
etc.). A simple proc.terminate() leaves children running, causing
lingering browser windows or zombie processes.
"""
if proc is None or proc.poll() is not None:
return
try:
subprocess.run(
['taskkill', '/F', '/T', '/PID', str(proc.pid)],
capture_output=True, timeout=5
)
except Exception:
# Fallback: try terminate + kill
try:
proc.terminate()
try:
proc.wait(timeout=3)
except Exception:
proc.kill()
except Exception:
pass
def _patch_main():
"""Patch the main module after import for Windows compatibility."""
# ── CRITICAL: Override Linux env vars BEFORE importing main ─────
# 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
# 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
# Store a reference to the original play_weblink so we can wrap it
_original_play_weblink = signage_main.SignagePlayer.play_weblink
def _windows_play_weblink(self, url, duration):
"""Windows-compatible weblink handler.
Strategy (tried in order):
1. CEF embedded browser (best — no subprocess, no z-order fights)
2. Chrome/Edge subprocess (fallback)
"""
from kivy.logger import Logger
from kivy.clock import Clock
from urllib.parse import urlparse
from playback_trace import trace
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
trace("win_weblink_REFUSED_scheme", scheme=scheme)
self.consecutive_errors += 1
self._skip_to_next_media()
return False
# ── Strategy 1: CEF embedded browser ────────────────────────
cef_browser = _get_cef_browser()
if cef_browser is not None:
Logger.info(f"SignagePlayer: Opening weblink via CEF (embedded in Kivy): {url}")
trace("win_weblink_CEF", url=url[:80])
try:
self.ids.content_area.opacity = 0
except Exception:
pass
# Attach resize handler so CEF follows Kivy window resizes
from kivy.core.window import Window as KivyWindow
_orig_on_resize = getattr(KivyWindow, '_on_resize', None)
def _cef_resize(*args):
try:
w, h = KivyWindow.size
cef_browser.resize(int(w), int(h))
except Exception:
pass
if _orig_on_resize:
try:
return _orig_on_resize(*args)
except Exception:
pass
KivyWindow._on_resize = _cef_resize
# Bind to size event as well
try:
KivyWindow.bind(size=_cef_resize)
except Exception:
pass
cef_browser.show(url)
Clock.unschedule(self.next_media)
self._start_inactivity_watchdog(duration)
self.preload_next_media()
Logger.info("SignagePlayer: CEF embedded browser visible (inside Kivy window)")
trace("win_weblink_CEF_shown")
return True
# ── Strategy 2: Subprocess Chrome/Edge (fallback) ────────────
browser = _windows_find_browser()
if not browser:
Logger.error(
"SignagePlayer: No embedded CEF and no Chrome/Edge found. "
"Cannot display weblink on Windows."
)
self.consecutive_errors += 1
self._skip_to_next_media()
return False
target_width, target_height = self._get_browser_target_size()
try:
Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})")
trace("win_weblink_subprocess", browser=os.path.basename(browser), url=url[:80])
self._kill_weblink_preload()
# Hide Kivy content (do NOT minimize — that makes it impossible
# to reliably bring Kivy back to foreground after Chrome closes).
from kivy.core.window import Window as KivyWindow
try:
self.ids.content_area.opacity = 0
except Exception:
pass
_Win32Overlay.show()
trace("win_overlay_shown")
# CRITICAL: use a dedicated --user-data-dir. Without it, Chrome
# hands the URL to the existing browser process and this launched
# process exits immediately (poll() != None), so the watchdog
# advances instantly and the weblink never displays. A private
# profile also guarantees a brand-new top-level window we can
# track, bring to front, and taskkill without touching the user's
# normal browser session.
profile_dir = os.path.join(
os.environ.get('KIWY_DATA_DIR', os.getcwd()),
'.kiosk-profile'
)
try:
os.makedirs(profile_dir, exist_ok=True)
except Exception:
pass
self._weblink_proc = subprocess.Popen([
browser,
'--user-data-dir=' + profile_dir,
'--kiosk',
'--new-window',
'--start-maximized',
'--start-fullscreen',
'--app=' + url,
'--no-first-run',
'--noerrdialogs',
'--disable-infobars',
'--incognito',
'--no-default-browser-check',
'--disable-session-crashed-bubble',
'--disable-features=TranslateUI',
'--disable-sync',
'--disable-background-networking',
'--window-position=0,0',
f'--window-size={target_width},{target_height}',
'--force-device-scale-factor=1',
url,
], shell=False)
# Hide the black overlay ONLY once Chrome's window is actually on
# screen. A fixed timer lets the desktop flash if Chrome is still
# starting (cold start / slow disk / GPU init). Adaptive polling
# keeps the screen black until the browser covers it.
weblink_proc = self._weblink_proc
_hide_overlay_when_chrome_ready(weblink_proc, timeout=6.0)
Clock.unschedule(self.next_media)
self._start_inactivity_watchdog(duration)
self.preload_next_media()
trace("win_weblink_subprocess_started", pid=weblink_proc.pid if weblink_proc else None)
return True
except Exception as e:
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
trace("win_weblink_EXCEPTION", error=str(e))
_Win32Overlay.hide()
self.consecutive_errors += 1
self._skip_to_next_media()
return False
# 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()
)
signage_main.SignagePlayer.play_weblink = _windows_play_weblink
# Patch the _get_browser_target_size to always return a reasonable size on Windows
def _windows_get_browser_target_size(self):
try:
from kivy.core.window import Window
width, height = Window.size
if width >= 1280 and height >= 720:
return int(width), int(height)
except Exception:
pass
return 1920, 1080
signage_main.SignagePlayer._get_browser_target_size = _windows_get_browser_target_size
# Patch _start_inactivity_watchdog for Windows — /dev/input does not exist
_original_watchdog = signage_main.SignagePlayer._start_inactivity_watchdog
def _windows_watchdog(self, duration):
"""Windows watchdog: uses a simple timer since /dev/input is not available.
Falls back to a fixed timer that advances after 'duration' seconds.
"""
from kivy.clock import Clock
import threading
self._stop_inactivity_watchdog()
stop_event = threading.Event()
self._watchdog_stop = stop_event
weblink_proc = self._weblink_proc
def watchdog():
import time
# Simply wait for the duration, checking if Chromium exited early
elapsed = 0.0
step = 0.5
while elapsed < duration and not stop_event.is_set():
if weblink_proc is not None and weblink_proc.poll() is not None:
Clock.schedule_once(self.next_media, 0)
return
time.sleep(step)
elapsed += step
if not stop_event.is_set():
Clock.schedule_once(self.next_media, 0)
self._weblink_watchdog_thread = threading.Thread(
target=watchdog, daemon=True, name='weblink-watchdog-win'
)
self._weblink_watchdog_thread.start()
signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog
# ── Patch kill_weblink_after_frame for both CEF and subprocess ──
def _windows_kill_weblink_after_frame(self):
"""Close the weblink (CEF or subprocess) immediately before next media.
Restores Kivy content visibility and brings the Kivy window to front
in all cases.
"""
import time
from kivy.logger import Logger
from kivy.clock import Clock
from kivy.core.window import Window as _KivyWindow
from playback_trace import trace
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
# Restore Kivy content visibility
try:
self.ids.content_area.opacity = 1
except Exception:
pass
# Try CEF first
cef_browser = _get_cef_browser()
if cef_browser is not None and cef_browser.is_showing():
Logger.info("SignagePlayer: Hiding CEF embedded browser")
trace("win_kill_weblink_CEF_hide")
cef_browser.hide()
self._weblink_proc = None
_bring_kivy_to_front()
trace("win_kivy_brought_front")
return
# Fallback: subprocess Chrome
proc = self._weblink_proc
self._weblink_proc = None
if proc is None or proc.poll() is not None:
_bring_kivy_to_front()
trace("win_kill_weblink_noop", proc_none=(proc is None))
return
_Win32Overlay.show()
Logger.info("SignagePlayer: Killing Chromium subprocess immediately")
trace("win_killing_chrome", pid=proc.pid)
_windows_kill_process_tree(proc)
time.sleep(0.1)
_bring_kivy_to_front()
_Win32Overlay.hide()
trace("win_chrome_killed_kivy_front")
signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame
# ── Patch play_current_media — same immediate-kill logic ────────
_original_play_current = signage_main.SignagePlayer.play_current_media
def _windows_play_current_media(self, force_reload=False, _after_weblink=False):
"""Wrapped play_current_media — closes weblink immediately on transition.
CRITICAL: Must restore content_area.opacity=1 BEFORE killing the browser,
because the original play_current_media() skips the weblink→media transition
block once self._weblink_proc is None. If opacity stays 0, the next widget
renders but is invisible.
"""
if not _after_weblink:
# Restore Kivy content visibility BEFORE killing the browser so the
# original play_current_media() doesn't need to handle the transition.
try:
self.ids.content_area.opacity = 1
except Exception:
pass
try:
from kivy.core.window import Window as _KivyWindow
_KivyWindow.show()
except Exception:
pass
# Kill CEF browser if showing
cef_browser = _get_cef_browser()
if cef_browser is not None and cef_browser.is_showing():
cef_browser.hide()
self._weblink_proc = None
_bring_kivy_to_front()
# Kill subprocess Chrome if running
proc = self._weblink_proc
if proc is not None and proc.poll() is None:
_Win32Overlay.show()
_windows_kill_process_tree(proc)
self._weblink_proc = None
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
_bring_kivy_to_front()
_Win32Overlay.hide()
return _original_play_current(self, force_reload=force_reload, _after_weblink=_after_weblink)
signage_main.SignagePlayer.play_current_media = _windows_play_current_media
# Patch _prewarm_weblink for Windows — disabled for now.
# The off-screen Chrome window on Windows can interfere with:
# - Audio playback (Chrome claims audio device)
# - GPU resources (Chrome's GPU process runs in background)
# - Taskbar icons showing duplicate Chrome windows
# Pre-warming is less critical on desktop where launch is already fast.
def _windows_prewarm_weblink(self, url):
pass # Disabled on Windows — desktop launch is fast enough
signage_main.SignagePlayer._prewarm_weblink = _windows_prewarm_weblink
# 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
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