12f2880201
v1 created a separate Win32 window — same problem as external Chrome. v2 creates CEF as a CHILD WINDOW of Kivy's SDL_app window: - No separate taskbar entry - No z-order fighting (CEF is INSIDE Kivy) - No desktop flash - CEF message loop pumped via Kivy Clock (main thread) - Resize handler attached so CEF follows Kivy window changes - build.spec includes cef_browser in hidden imports
844 lines
33 KiB
Python
844 lines
33 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().
|
|
|
|
def _windows_screen_activity(self, dt):
|
|
"""Windows alternative to Linux screen-keep-awake commands.
|
|
|
|
Uses SetThreadExecutionState via ctypes to tell Windows to keep
|
|
the display and system awake.
|
|
"""
|
|
try:
|
|
# ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_CONTINUOUS
|
|
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
|
|
|
|
|
|
# ── 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 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.
|
|
"""
|
|
|
|
_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
|
|
|
|
|
|
def _bring_kivy_to_front():
|
|
"""Bring the Kivy/SDL window to foreground using win32gui.
|
|
|
|
Unlike Window.raise_window(), win32gui.SetForegroundWindow
|
|
actually works reliably on Windows — it uses the same Win32
|
|
API that the Task Manager uses.
|
|
"""
|
|
try:
|
|
import win32gui
|
|
import win32con
|
|
|
|
def _enum_cb(hwnd, hwnd_list):
|
|
cls = win32gui.GetClassName(hwnd)
|
|
title = win32gui.GetWindowText(hwnd)
|
|
if cls == "SDL_app":
|
|
hwnd_list.append(hwnd)
|
|
elif "Kiwy" in title or "Signage" in title:
|
|
hwnd_list.append(hwnd)
|
|
|
|
hwnd_list = []
|
|
win32gui.EnumWindows(_enum_cb, hwnd_list)
|
|
|
|
if hwnd_list:
|
|
kivy_hwnd = hwnd_list[-1] # most recent
|
|
win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL)
|
|
win32gui.SetForegroundWindow(kivy_hwnd)
|
|
win32gui.BringWindowToTop(kivy_hwnd)
|
|
except Exception:
|
|
# Fallback to Kivy's built-in raise
|
|
try:
|
|
from kivy.core.window import Window
|
|
Window.show()
|
|
Window.raise_window()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _windows_kill_process_tree(proc):
|
|
"""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
|
|
|
|
scheme = urlparse(url).scheme.lower()
|
|
if scheme not in ('http', 'https'):
|
|
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
|
|
self.consecutive_errors += 1
|
|
self._skip_to_next_media()
|
|
return False
|
|
|
|
# ── Strategy 1: CEF embedded browser ────────────────────────
|
|
cef_browser = _get_cef_browser()
|
|
if cef_browser is not None:
|
|
Logger.info(f"SignagePlayer: Opening weblink via CEF (embedded in Kivy): {url}")
|
|
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)")
|
|
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})")
|
|
self._kill_weblink_preload()
|
|
|
|
# Hide Kivy content
|
|
from kivy.core.window import Window as KivyWindow
|
|
try:
|
|
self.ids.content_area.opacity = 0
|
|
KivyWindow.minimize()
|
|
except Exception:
|
|
pass
|
|
|
|
_Win32Overlay.show()
|
|
|
|
self._weblink_proc = subprocess.Popen([
|
|
browser,
|
|
'--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)
|
|
|
|
def _hide_overlay(dt):
|
|
_Win32Overlay.hide()
|
|
try:
|
|
KivyWindow.raise_window()
|
|
except Exception:
|
|
pass
|
|
Clock.schedule_once(_hide_overlay, 1.5)
|
|
|
|
Clock.unschedule(self.next_media)
|
|
self._start_inactivity_watchdog(duration)
|
|
self.preload_next_media()
|
|
return True
|
|
|
|
except Exception as e:
|
|
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
|
|
_Win32Overlay.hide()
|
|
self.consecutive_errors += 1
|
|
self._skip_to_next_media()
|
|
return False
|
|
|
|
# Replace weblink handling
|
|
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."""
|
|
import time
|
|
from kivy.logger import Logger
|
|
from kivy.clock import Clock
|
|
self._stop_inactivity_watchdog()
|
|
self._kill_weblink_preload()
|
|
|
|
# Try CEF first
|
|
cef_browser = _get_cef_browser()
|
|
if cef_browser is not None and cef_browser.is_showing():
|
|
Logger.info("SignagePlayer: Hiding CEF embedded browser")
|
|
cef_browser.hide()
|
|
self._weblink_proc = None
|
|
return
|
|
|
|
# Fallback: subprocess Chrome
|
|
proc = self._weblink_proc
|
|
self._weblink_proc = None
|
|
|
|
if proc is None or proc.poll() is not None:
|
|
return
|
|
|
|
_Win32Overlay.show()
|
|
Logger.info("SignagePlayer: Killing Chromium subprocess immediately")
|
|
_windows_kill_process_tree(proc)
|
|
time.sleep(0.1)
|
|
_bring_kivy_to_front()
|
|
_Win32Overlay.hide()
|
|
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."""
|
|
if not _after_weblink:
|
|
# Kill CEF browser if showing
|
|
cef_browser = _get_cef_browser()
|
|
if cef_browser is not None and cef_browser.is_showing():
|
|
cef_browser.hide()
|
|
self._weblink_proc = None
|
|
|
|
# Kill subprocess Chrome if running
|
|
proc = self._weblink_proc
|
|
if proc is not None and proc.poll() is None:
|
|
_Win32Overlay.show()
|
|
_windows_kill_process_tree(proc)
|
|
self._weblink_proc = None
|
|
self._stop_inactivity_watchdog()
|
|
self._kill_weblink_preload()
|
|
_bring_kivy_to_front()
|
|
_Win32Overlay.hide()
|
|
|
|
return _original_play_current(self, force_reload=force_reload, _after_weblink=_after_weblink)
|
|
|
|
signage_main.SignagePlayer.play_current_media = _windows_play_current_media
|
|
|
|
# Patch _prewarm_weblink for Windows — disabled for now.
|
|
# The off-screen Chrome window on Windows can interfere with:
|
|
# - Audio playback (Chrome claims audio device)
|
|
# - GPU resources (Chrome's GPU process runs in background)
|
|
# - Taskbar icons showing duplicate Chrome windows
|
|
# Pre-warming is less critical on desktop where launch is already fast.
|
|
def _windows_prewarm_weblink(self, url):
|
|
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
|
|
|
|
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
|
|
|
|
# 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")
|
|
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
|