""" 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 # 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 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 _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 _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).""" if proc is None: return try: import win32gui import win32process except Exception: return 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's top-level window is class 'Chrome_WidgetWin_1' (or 0) 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 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 if chrome_hwnd is not None: _bring_hwnd_to_front(chrome_hwnd) def _windows_kill_process_tree(proc): """Kill a process AND all its children using taskkill. Chrome/Edge spawns many child processes (GPU, renderer, network, etc.). A simple proc.terminate() leaves children running, causing lingering browser windows or zombie processes. """ if proc is None or proc.poll() is not None: return try: subprocess.run( ['taskkill', '/F', '/T', '/PID', str(proc.pid)], capture_output=True, timeout=5 ) except Exception: # Fallback: try terminate + kill try: proc.terminate() try: proc.wait(timeout=3) except Exception: proc.kill() except Exception: pass def _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 (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() # 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, 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. weblink_proc = self._weblink_proc def _hide_overlay(dt): _Win32Overlay.hide() _bring_chrome_to_front(weblink_proc) Clock.schedule_once(_hide_overlay, 1.0) 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. 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 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") cef_browser.hide() self._weblink_proc = None _bring_kivy_to_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() 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. 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 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