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)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
+2
-1
@@ -285,7 +285,8 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=str(RESOURCES_DIR / 'app_icon.ico') if (RESOURCES_DIR / 'app_icon.ico').exists() else None,
|
||||
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
||||
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
||||
)
|
||||
|
||||
# --- COLLECT everything into a single folder -------------------------
|
||||
|
||||
@@ -76,7 +76,21 @@ in `windows/run_win.py`. All are covered except the one listed below:
|
||||
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||
instead of raising Kivy.
|
||||
- **Test:** exe rebuilt 2026-07-31 13:53; DLL set intact (28 DLLs incl. FFmpeg).
|
||||
- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch
|
||||
args so the browser opens in true kiosk mode (no UI/chrome, locks to screen).
|
||||
Safe with the dedicated `--user-data-dir` — does not affect the user's normal
|
||||
browser session.
|
||||
- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with
|
||||
**adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now
|
||||
stays up until Chrome's window is actually detected on screen
|
||||
(`_find_chrome_hwnd`), so the host desktop is never exposed during cold
|
||||
starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout.
|
||||
- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a
|
||||
fullscreen black window created at player startup (`_Win32Backdrop.show()`)
|
||||
placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop),
|
||||
destroyed only on clean exit. Any browser load/unload gap now reveals clean
|
||||
black instead of the host desktop.
|
||||
- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg).
|
||||
|
||||
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||
|
||||
+482
-28
@@ -92,23 +92,95 @@ sys.modules['evdev.InputDevice'] = _FakeEvdevInputDevice
|
||||
# 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().
|
||||
|
||||
def _windows_screen_activity(self, dt):
|
||||
"""Windows alternative to Linux screen-keep-awake commands.
|
||||
# Keep-awake state so we can restore the screensaver on exit.
|
||||
_SAVED_SCREENSAVER_ACTIVE = None # True/False once read; None = unknown
|
||||
|
||||
Uses SetThreadExecutionState via ctypes to tell Windows to keep
|
||||
the display and system awake.
|
||||
|
||||
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_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_CONTINUOUS
|
||||
# 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 ──────────────────────────
|
||||
@@ -166,10 +238,13 @@ def _windows_find_browser():
|
||||
class _Win32Overlay:
|
||||
"""Fullscreen black overlay window to mask desktop during transitions.
|
||||
|
||||
When switching away from Chromium, the browser window disappears and
|
||||
there is a brief moment where the desktop is visible before Kivy
|
||||
manages to bring its window to the front. This overlay covers that
|
||||
flash with a pure-black borderless always-on-top Win32 window.
|
||||
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
|
||||
@@ -258,6 +333,74 @@ class _Win32Overlay:
|
||||
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).
|
||||
@@ -273,6 +416,194 @@ _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.
|
||||
@@ -341,6 +672,24 @@ def _find_kivy_hwnd():
|
||||
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.
|
||||
|
||||
@@ -363,16 +712,19 @@ def _bring_kivy_to_front():
|
||||
pass
|
||||
|
||||
|
||||
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)."""
|
||||
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
|
||||
return None
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
except Exception:
|
||||
return
|
||||
return None
|
||||
|
||||
target_pid = proc.pid
|
||||
chrome_hwnd = None
|
||||
@@ -391,7 +743,7 @@ def _bring_chrome_to_front(proc):
|
||||
cls = win32gui.GetClassName(hwnd)
|
||||
except Exception:
|
||||
return
|
||||
# Chrome's top-level window is class 'Chrome_WidgetWin_1' (or 0)
|
||||
# Chrome/Edge top-level window classes
|
||||
if cls in ('Chrome_WidgetWin_1', 'Chrome_WidgetWin_0', 'ApplicationFrameWindow'):
|
||||
if win32gui.IsWindowVisible(hwnd):
|
||||
chrome_hwnd = hwnd
|
||||
@@ -400,21 +752,67 @@ def _bring_chrome_to_front(proc):
|
||||
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)
|
||||
try:
|
||||
win32gui.EnumWindows(_enum_cb, None)
|
||||
except Exception:
|
||||
pass
|
||||
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.
|
||||
|
||||
@@ -482,9 +880,11 @@ def _patch_main():
|
||||
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
|
||||
@@ -493,6 +893,7 @@ def _patch_main():
|
||||
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:
|
||||
@@ -523,6 +924,7 @@ def _patch_main():
|
||||
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) ────────────
|
||||
@@ -540,6 +942,7 @@ def _patch_main():
|
||||
|
||||
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
|
||||
@@ -551,6 +954,7 @@ def _patch_main():
|
||||
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
|
||||
@@ -591,29 +995,41 @@ def _patch_main():
|
||||
url,
|
||||
], shell=False)
|
||||
|
||||
# Hide the black overlay, then bring CHROME to the front — NOT
|
||||
# Kivy. Kivy is a borderless fullscreen window; if we raise Kivy
|
||||
# here the weblink would open *behind* it and never be visible.
|
||||
# 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
|
||||
|
||||
def _hide_overlay(dt):
|
||||
_Win32Overlay.hide()
|
||||
_bring_chrome_to_front(weblink_proc)
|
||||
Clock.schedule_once(_hide_overlay, 1.0)
|
||||
_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
|
||||
@@ -677,6 +1093,7 @@ def _patch_main():
|
||||
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()
|
||||
|
||||
@@ -690,9 +1107,11 @@ def _patch_main():
|
||||
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
|
||||
@@ -701,14 +1120,17 @@ def _patch_main():
|
||||
|
||||
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 ────────
|
||||
@@ -831,6 +1253,27 @@ def _patch_main():
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -892,6 +1335,15 @@ if __name__ == '__main__':
|
||||
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()
|
||||
@@ -997,6 +1449,8 @@ if __name__ == '__main__':
|
||||
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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# UTF-8
|
||||
#
|
||||
# Windows version resource for KiwySignagePlayer.exe
|
||||
# This file is used by PyInstaller (version=) to embed publisher/product
|
||||
# metadata into the executable so Windows Smart App Control / SmartScreen
|
||||
# can identify the app instead of flagging it as "Unknown publisher".
|
||||
#
|
||||
# Note: A code-signing certificate is still required for a fully trusted
|
||||
# publisher name; this metadata at least names the product/company and
|
||||
# supplies a version number.
|
||||
#
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 2, 0, 0),
|
||||
prodvers=(1, 2, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo(
|
||||
[
|
||||
StringTable(
|
||||
'040904B0',
|
||||
[
|
||||
StringStruct('CompanyName', 'Kiwy Signage'),
|
||||
StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'),
|
||||
StringStruct('FileVersion', '1.2.0.0'),
|
||||
StringStruct('InternalName', 'KiwySignagePlayer'),
|
||||
StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'),
|
||||
StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'),
|
||||
StringStruct('ProductName', 'Kiwy Signage Player'),
|
||||
StringStruct('ProductVersion', '1.2.0.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user