""" 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 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 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 using Chrome/Edge.""" browser = _windows_find_browser() if not browser: from kivy.logger import Logger Logger.error( "SignagePlayer: Chrome/Edge not found. " "Cannot display weblink on Windows." ) self.consecutive_errors += 1 self._skip_to_next_media() return False from urllib.parse import urlparse scheme = urlparse(url).scheme.lower() if scheme not in ('http', 'https'): from kivy.logger import Logger Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}") self.consecutive_errors += 1 self._skip_to_next_media() return False import subprocess from kivy.logger import Logger from kivy.clock import Clock try: Logger.info(f"SignagePlayer: Opening weblink: {url} (browser: {browser})") self._kill_weblink_preload() # Launch in fullscreen / kiosk-like mode self._weblink_proc = subprocess.Popen([ browser, '--kiosk', '--no-first-run', '--noerrdialogs', '--disable-infobars', '--incognito', '--no-default-browser-check', '--disable-session-crashed-bubble', '--disable-features=TranslateUI', url, ], shell=False) 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}") 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 the _kill_weblink_after_frame to work without Linux-specific code def _windows_kill_weblink_after_frame(self): from kivy.clock import Clock self._stop_inactivity_watchdog() self._kill_weblink_preload() proc = self._weblink_proc self._weblink_proc = None if proc is not None and proc.poll() is None: def _do_kill(dt): if proc.poll() is None: try: proc.terminate() try: proc.wait(timeout=3) except Exception: proc.kill() except Exception: pass Clock.schedule_once(_do_kill, 0) signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame # Patch _prewarm_weblink for Windows def _windows_prewarm_weblink(self, url): from urllib.parse import urlparse if not url: return scheme = urlparse(url).scheme.lower() if scheme not in ('http', 'https'): return browser = _windows_find_browser() if not browser: return import subprocess from kivy.logger import Logger self._kill_weblink_preload() try: Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}") self._weblink_preload_proc = subprocess.Popen([ browser, '--incognito', '--no-first-run', '--window-position=-9999,-9999', url, ], shell=False) except Exception as exc: Logger.debug(f"SignagePlayer: Pre-warm failed (non-fatal): {exc}") self._weblink_preload_proc = None 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