From 31ad592e98114f850611f324fe77458ec1d35ed4 Mon Sep 17 00:00:00 2001 From: ske087 Date: Tue, 4 Aug 2026 15:57:33 +0300 Subject: [PATCH] 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) --- src/main.py | 458 ++++++++++++++++++++++++++++--- src/playback_trace.py | 64 +++++ src/signage_player.kv | 25 +- windows/app_icon.ico | Bin 0 -> 43979 bytes windows/build.spec | 3 +- windows/development-track.md | 16 +- windows/run_win.py | 510 +++++++++++++++++++++++++++++++++-- windows/version_info.txt | 43 +++ 8 files changed, 1048 insertions(+), 71 deletions(-) create mode 100644 src/playback_trace.py create mode 100644 windows/app_icon.ico create mode 100644 windows/version_info.txt diff --git a/src/main.py b/src/main.py index 1449a94..0f72fb6 100644 --- a/src/main.py +++ b/src/main.py @@ -8,6 +8,7 @@ PLAYER_VERSION = "1.2.0" import os import json import platform +import signal import threading import time import asyncio @@ -35,6 +36,10 @@ from kivy.config import Config # Performance optimizations for video playback Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard +# Kiosk requirement: Escape must NEVER close the player. The only exit path +# is the password-protected exit button. This stays disabled even during +# development so exit behavior always mirrors production. +Config.set('kivy', 'exit_on_escape', '0') Config.set('graphics', 'fullscreen', '0') # Will be set to 1 later Config.set('graphics', 'window_state', 'maximized') # Maximize window @@ -85,6 +90,7 @@ from edit_popup import DrawingLayer, EditPopup from kivy.graphics import Color, Line, Ellipse from kivy.uix.floatlayout import FloatLayout from kivy.uix.slider import Slider +from playback_trace import trace # always-on playback transition logger # Load the KV file - resolve relative to this file's directory _kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv') @@ -581,16 +587,8 @@ class ExitPasswordPopup(Popup): """Handle popup dismissal - resume playback and restart cursor hide timer""" # Hide and remove keyboard self.hide_keyboard() - - # Resume playback if it wasn't paused before - if not self.was_paused: - self.player.is_paused = False - # Resume video if it was playing - if self.player.current_widget and isinstance(self.player.current_widget, Video): - self.player.current_widget.state = 'play' - - # Restart the control hide timer - self.player.schedule_hide_controls() + # Resume playback and re-arm the media advance timer + self.player.resume_after_popup(self.was_paused) def check_password(self): """Check if entered password matches quickconnect key""" @@ -613,6 +611,8 @@ class ExitPasswordPopup(Popup): except Exception as e: Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}") + # Allow the app to close (releases the production-mode close guard) + self.player.set_allow_exit(True) self.dismiss() App.get_running_app().stop() else: @@ -658,7 +658,10 @@ class SettingsPopup(Popup): self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}' self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}' self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}' - + + # Refresh the production-mode button to reflect the current state + self.update_production_button() + # Bind to dismiss event to manage cursor visibility and resume playback self.bind(on_dismiss=self.on_popup_dismiss) @@ -703,17 +706,45 @@ class SettingsPopup(Popup): """Handle popup dismissal - resume playback and restart cursor hide timer""" # Hide and remove keyboard self.hide_keyboard() - - # Resume playback if it wasn't paused before - if not self.was_paused: - self.player.is_paused = False - # Resume video if it was playing - if self.player.current_widget and isinstance(self.player.current_widget, Video): - self.player.current_widget.state = 'play' - - # Restart the control hide timer - self.player.schedule_hide_controls() + # Resume playback and re-arm the media advance timer + self.player.resume_after_popup(self.was_paused) + def update_production_button(self): + """Refresh the production-mode button to reflect the current state. + + Green when production (kiosk) mode is enabled, grey when disabled. + """ + production = bool(self.player.config.get('production_mode', False)) + btn = self.ids.production_mode_btn + if production: + btn.background_color = (0.2, 0.7, 0.2, 1) # green + btn.text = 'Production ON' + else: + btn.background_color = (0.4, 0.4, 0.4, 1) # grey + btn.text = 'Enable Production' + + def toggle_production_mode(self): + """Toggle production (kiosk) mode and apply the lockdown immediately.""" + production = not bool(self.player.config.get('production_mode', False)) + Logger.info( + f"SettingsPopup: {'Enabling' if production else 'Disabling'} " + "production mode" + ) + # Apply lockdown (exit_on_escape, close guard, Ctrl+C, platform hooks) + self.player.apply_kiosk_mode(production) + self.player.save_config() + self.update_production_button() + if production: + self._show_temp_message( + '✓ Production mode ENABLED — kiosk lockdown active', + (0, 1, 0, 1) + ) + else: + self._show_temp_message( + '✓ Production mode DISABLED — development mode', + (0.8, 0.8, 0.8, 1) + ) + def test_connection(self): """Test connection to server with current credentials""" # Update status label to show testing @@ -933,6 +964,16 @@ class SignagePlayer(Widget): self.auto_resume_event = None # Track scheduled auto-resume self.config = {} self.playlist_version = None + self._allow_exit = False # Set True by the password exit flow to allow the app to close + self._media_started_at = None # monotonic time the current media started + self._media_duration = 10 # scheduled duration of the current media + self._video_eos_pending = False # guards against duplicate EOS callbacks on one video + self._last_advance_at = 0.0 # monotonic time of the last next_media advance (dedupe guard) + self._focus_keeper_event = None # Clock interval that keeps the window focused during video + self._focus_keeper_elapsed = 0.0 + self._focus_keeper_duration = 0.0 + self._video_watchdog_event = None # Clock interval that watches video progress + self._video_watchdog_stopped = False # self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes) self.consecutive_errors = 0 # Track consecutive playback errors self.max_consecutive_errors = 10 # Maximum errors before stopping @@ -958,6 +999,11 @@ class SignagePlayer(Widget): # Bind to window size for fullscreen Window.bind(size=self._update_size) self._update_size(Window, Window.size) + # Bind the window-close guard. In production mode it returns True + # (blocking X / Alt+F4 / any WM close) until the password exit flow + # calls set_allow_exit(True). In development mode it returns False so + # the window closes normally. + Window.bind(on_request_close=self._guard_window_close) # Initialize player Clock.schedule_once(self.initialize_player, 0.1) # Hide controls timer @@ -973,7 +1019,97 @@ class SignagePlayer(Widget): self.size = value if hasattr(self, 'ids') and 'content_area' in self.ids: self.ids.content_area.size = value - + + def _guard_window_close(self, *args, **kwargs): + """Block window-close attempts in production mode unless exit allowed. + + Bound to Window's `on_request_close` event. Kivy's SDL2 provider + dispatches this for the window close button / Alt+F4 / WM close. + Returning True cancels the close. The password exit flow calls + set_allow_exit(True) just before App.stop(), which lets the app + terminate even while production mode is active. + """ + if not self.config.get('production_mode', False): + return False # development mode — allow normal window close + if getattr(self, '_allow_exit', False): + return False # legitimate password-protected exit + Logger.info("SignagePlayer: Window close blocked (production mode)") + return True + + def set_allow_exit(self, allow=True): + """Allow the app to actually close (called by the password exit flow).""" + self._allow_exit = bool(allow) + + def resume_after_popup(self, was_paused): + """Resume playback and re-arm the media advance timer after a modal + popup (settings / exit-password) is dismissed. + + `show_settings()` / `show_exit_popup()` unschedule next_media while + the popup is open. Without re-arming here the current media would sit + forever once the popup closes, so the playlist stops advancing. + """ + # Always restart the control-hide timer. + self.schedule_hide_controls() + if was_paused: + return # user had already paused playback — stay paused + + self.is_paused = False + # Resume a paused video. + if self.current_widget and isinstance(self.current_widget, Video): + try: + self.current_widget.state = 'play' + except Exception: + pass + + # Weblinks are advanced by their own watchdog thread — don't touch. + if getattr(self, '_weblink_proc', None) is not None: + return + + # Re-arm the advance timer with the remaining time. + started = getattr(self, '_media_started_at', None) + duration = getattr(self, '_media_duration', 10) + remaining = duration + if started is not None: + remaining = duration - (time.monotonic() - started) + remaining = max(0.5, remaining) + Logger.debug( + f"SignagePlayer: Re-arming next_media in {remaining:.1f}s after popup" + ) + Clock.unschedule(self.next_media) + Clock.schedule_once(self.next_media, remaining) + + def apply_kiosk_mode(self, enabled): + """Enable/disable production (kiosk) lockdown. + + When enabled: + - Escape never exits the app (exit_on_escape stays 0). + - Window close (X / Alt+F4) is blocked by _guard_window_close until + the password exit flow calls set_allow_exit(True). + - Ctrl+C is ignored so the console cannot kill the player. + Platform wrappers (e.g. run_win.py) may extend this to also swallow + Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook. + """ + self.config['production_mode'] = bool(enabled) + if enabled: + # Defense in depth: ensure Escape can never close the player. + try: + Config.set('kivy', 'exit_on_escape', '0') + except Exception: + pass + # Ignore Ctrl+C in production — the console must not kill the app. + try: + signal.signal(signal.SIGINT, signal.SIG_IGN) + except Exception: + pass + Logger.info("SignagePlayer: PRODUCTION MODE ENABLED — kiosk lockdown active") + else: + # Development mode: restore the default Ctrl+C handler. + try: + signal.signal(signal.SIGINT, signal.default_int_handler) + except Exception: + pass + Logger.info("SignagePlayer: Production mode disabled — development mode") + def update_heartbeat(self, dt): """Update heartbeat file to indicate player is alive""" try: @@ -1046,7 +1182,10 @@ class SignagePlayer(Widget): # Load configuration self.load_config() - + + # Apply persisted production/kiosk mode (exit lockdown) if enabled + self.apply_kiosk_mode(self.config.get('production_mode', False)) + # Initialize network monitor self.start_network_monitoring() @@ -1077,7 +1216,8 @@ class SignagePlayer(Widget): "quickconnect_key": "1234567", "max_resolution": "auto", "use_https": True, - "verify_ssl": True + "verify_ssl": True, + "production_mode": False } self.save_config() Logger.info("SignagePlayer: Created default configuration with HTTPS enabled") @@ -1312,8 +1452,20 @@ class SignagePlayer(Widget): media_item = self.playlist[self.current_index] file_name = media_item.get('file_name', '') duration = media_item.get('duration', 10) + # Track start time + duration so a popup can re-arm the advance + # timer with the correct remaining time after dismissal. + self._media_started_at = time.monotonic() + self._media_duration = duration Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)") + trace("play_current_media", + index=self.current_index + 1, + total=len(self.playlist), + name=file_name, + type=media_item.get('type', '?'), + duration=duration, + after_weblink=_after_weblink, + weblink_proc=bool(getattr(self, '_weblink_proc', None))) # ── Weblink → media transition (desktop-flash safe) ────────────── # For web→media transitions, render the next Kivy widget first, @@ -1373,6 +1525,7 @@ class SignagePlayer(Widget): # Handle web links before any file/path handling (no local file exists) if media_item.get('type') == 'weblink': Logger.debug("SignagePlayer: Media type: WEBLINK") + trace("enter_weblink_branch", url=media_item.get('url', '')[:80]) self.ids.status_label.opacity = 0 self._remove_current_widget() # Hide content_area — Chromium will cover it; avoids stale frame @@ -1381,6 +1534,7 @@ class SignagePlayer(Widget): except Exception: pass started = self.play_weblink(media_item.get('url', ''), duration) + trace("weblink_started", ok=bool(started)) if started: self.consecutive_errors = 0 if self.config: @@ -1420,10 +1574,12 @@ class SignagePlayer(Widget): if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']: # Video file Logger.debug(f"SignagePlayer: Media type: VIDEO") + trace("starting_video", path=media_path) self.play_video(media_path, duration) elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']: # Image file Logger.debug(f"SignagePlayer: Media type: IMAGE") + trace("starting_image", path=media_path) self.play_image(media_path, duration, force_reload=force_reload) else: Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}") @@ -1451,10 +1607,12 @@ class SignagePlayer(Widget): # If we arrived here from a weblink item, close Chromium after the # next Kivy frame so the new widget is already visible underneath. if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None: + trace("closing_weblink_after_frame", name=file_name) self._kill_weblink_after_frame() except Exception as e: Logger.error(f"SignagePlayer: Error playing media: {e}") + trace("play_current_media_EXCEPTION", error=str(e)) self.consecutive_errors += 1 # Check if we've exceeded max errors @@ -1481,6 +1639,7 @@ class SignagePlayer(Widget): Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") # Create Video widget with optimized settings for smooth playback + self._video_source = video_path self.current_widget = Video( source=video_path, state='play', # Start playing immediately @@ -1506,7 +1665,25 @@ class SignagePlayer(Widget): # Add to content area self.ids.content_area.add_widget(self.current_widget) - # Schedule next media after duration (unschedule first to prevent overlaps) + # Start the focus keeper so the window stays foreground for the + # whole video duration (handles SDL surface swaps at load and any + # later re-swaps). The keeper is non-blocking. + self._start_focus_keeper(duration + 1) + trace("video_focus_keeper_started") + + # Start a progress watchdog. ffpyplayer does NOT reliably dispatch + # EOS — the trace shows the video looping its tail for the gap + # between its real end (~30s for sample-30s.mp4) and the fixed + # timer (31s). The watchdog polls the actual position and + # force-advances as soon as the video really ends. + self._video_watchdog_stopped = False + self._video_watchdog_event = Clock.schedule_interval( + self._video_watchdog_tick, 0.5 + ) + trace("video_watchdog_started", duration=duration) + + # Schedule a safety-net advance after the playlist duration + # (unschedule first to prevent overlaps). Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s") Clock.unschedule(self.next_media) Clock.schedule_once(self.next_media, duration) @@ -1519,20 +1696,155 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() + def _video_watchdog_tick(self, dt): + """Watch the current video's progress and force-advance at its end. + + ffpyplayer sometimes fails to dispatch EOS, leaving the video looping + its last frames for the gap between the true clip end and the playlist + timer. This polls position vs duration and advances as soon as the + video actually finishes, so the tail-loop never shows. + """ + if self._video_watchdog_stopped: + return + w = getattr(self, 'current_widget', None) + if not isinstance(w, Video) or w.source != getattr(self, '_video_source', None): + self._stop_video_watchdog() + return + try: + duration = getattr(w, 'duration', 0.0) or 0.0 + position = getattr(w, 'position', -1) or 0.0 + if duration > 0 and position >= duration - 0.4: + trace( + "video_watchdog_advance", + pos=round(position, 2), + dur=round(duration, 2), + ) + self._stop_video_watchdog() + Clock.unschedule(self.next_media) + Clock.schedule_once(self._advance_after_video_eos, 0.1) + except Exception: + pass + + def _stop_video_watchdog(self): + """Cancel the video progress watchdog.""" + self._video_watchdog_stopped = True + ev = getattr(self, '_video_watchdog_event', None) + if ev is not None: + try: + Clock.unschedule(ev) + except Exception: + pass + self._video_watchdog_event = None + + def _bring_window_to_front_nonblocking(self): + """Bring the Kivy window forward WITHOUT stalling the UI. + + The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput + + SetForegroundWindow) can take ~1.5s and was blocking the main thread + at every video start (see playback_trace.log: video_loaded then +1.5s + before video_focus_reasserted). Running it on a background thread + keeps playback smooth. + """ + def _do(): + try: + from kivy.core.window import Window as _KivyWindow + _KivyWindow.raise_window() + except Exception: + pass + try: + _bring = getattr(self, '_bring_kivy_to_front_win', None) + if _bring is not None: + _bring() + except Exception: + pass + threading.Thread(target=_do, daemon=True, name='focus-bring-front').start() + + def _start_focus_keeper(self, duration): + """Periodically keep the Kivy window in the foreground. + + Runs for up to `duration` seconds while a video is on screen. Each tick + first does a CHEAP foreground check; the expensive bring-to-front only + runs when focus was actually lost, and even then on a background + thread so the UI never stalls. + """ + self._stop_focus_keeper() + self._focus_keeper_elapsed = 0.0 + self._focus_keeper_duration = float(duration) + self._focus_keeper_event = Clock.schedule_interval( + self._focus_keeper_tick, 0.5 + ) + trace("focus_keeper_started", duration=duration) + + def _focus_keeper_tick(self, dt): + """One focus-keeper tick: cheap check, heavy action only if needed.""" + self._focus_keeper_elapsed += dt + if self._focus_keeper_elapsed > self._focus_keeper_duration: + self._stop_focus_keeper() + return + # Cheap foreground check first — avoids the 1.5s Win32 work entirely + # when the window already has focus. + try: + check = getattr(self, '_is_foreground_win', None) + if check is not None and check(): + return # already focused, nothing to do + except Exception: + pass + trace("focus_keeper_focus_lost") + self._bring_window_to_front_nonblocking() + + def _stop_focus_keeper(self): + """Cancel any active focus keeper interval.""" + ev = getattr(self, '_focus_keeper_event', None) + if ev is not None: + try: + Clock.unschedule(ev) + except Exception: + pass + self._focus_keeper_event = None + self._focus_keeper_elapsed = 0.0 + def _on_video_eos(self, instance): - """Callback when video reaches end of stream""" + """Callback when video reaches end of stream. + + Guarded against re-entrancy: ffpyplayer can dispatch EOS more than once + for a single video (thread + main-thread paths), and duplicate advance + schedules caused skipped items / crashes on later playlist loops. + """ + if self._video_eos_pending: + trace("video_eos_IGNORED_duplicate", state=getattr(instance, 'state', '?')) + return # already handled for this video + self._video_eos_pending = True + self._stop_video_watchdog() Logger.debug("SignagePlayer: Video finished playing (EOS)") - # Unschedule any pending timer and advance to next media + trace("video_eos", state=getattr(instance, 'state', '?')) + # NOTE: do NOT set instance.state = 'stop' here on the main thread — + # in Kivy that triggers VideoFFPy.stop() -> unload() -> thread.join(), + # a blocking call that freezes the UI. The teardown worker thread in + # _remove_current_widget does the stop+unload off the main thread. + # Unschedule any pending timer and advance to next media exactly once Clock.unschedule(self.next_media) - Clock.schedule_once(self.next_media, 0.5) + Clock.schedule_once(self._advance_after_video_eos, 0.5) + + def _advance_after_video_eos(self, dt): + """Advance to the next media after a video ended (single-fire).""" + self._video_eos_pending = False + self._stop_video_watchdog() + trace("advance_after_video_eos", index=self.current_index) + self.next_media() def _on_video_loaded(self, instance, value): - """Callback when video is loaded - log video information""" + """Callback when the video's first frame is decoded and loaded. + + The SDL surface swap at this moment can drop the window from the + foreground. The periodic focus keeper (started in play_video) handles + keeping it focused; here we just log it. + """ if value: try: Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s") except Exception as e: Logger.debug(f"SignagePlayer: Could not log video info: {e}") + trace("video_loaded") def play_image(self, image_path, duration, force_reload=False): """Play an image file""" @@ -1574,20 +1886,79 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() + def _teardown_video_async(self, widget): + """Stop and unload a finished video OFF the main thread. + + CRITICAL: Kivy's `Video.state = 'stop'` calls VideoFFPy.stop() -> + unload() -> self._thread.join(), which BLOCKS until the ffpyplayer + decode thread exits. That join is variable (0.4s up to 100s+) and was + freezing the whole UI thread at every video->next transition (see + playback_trace.log gaps between remove_current_widget and + widget_removed_async). Doing the full stop+unload on a worker thread + keeps the UI responsive. + """ + try: + # Rebind the same widget's EOS is not needed; just stop+unload. + widget.state = 'stop' + except Exception: + pass + try: + widget.unload() + except Exception: + pass + def _remove_current_widget(self): """Stop and remove the current Kivy media widget if one is present.""" + trace("remove_current_widget", + has=bool(self.current_widget), + is_video=isinstance(self.current_widget, Video)) if self.current_widget: # Properly stop video if it's playing to prevent resource leaks if isinstance(self.current_widget, Video): try: Logger.debug("SignagePlayer: Stopping previous video widget...") - self.current_widget.state = 'stop' - self.current_widget.unload() + # Unbind EOS first so stopping/unloading cannot re-trigger + # the transition callback (caused double-advance / crashes + # at the video -> next-media boundary). + try: + self.current_widget.unbind(on_eos=self._on_video_eos) + except Exception: + pass + # Do NOT set state='stop' on the main thread — in Kivy that + # triggers VideoFFPy.stop() -> unload() -> thread.join(), a + # blocking call that froze the UI for up to 100s+ (see + # playback_trace.log gaps between remove_current_widget and + # widget_removed_async). Detach immediately and let the + # worker thread do the full stop+unload. + try: + self.ids.content_area.remove_widget(self.current_widget) + except Exception: + pass + widget = self.current_widget + self.current_widget = None + self._video_eos_pending = False + self._stop_focus_keeper() + self._stop_video_watchdog() + threading.Thread( + target=self._teardown_video_async, + args=(widget,), daemon=True, + name='video-teardown' + ).start() + trace("widget_removed_async") + return except Exception as e: Logger.warning(f"SignagePlayer: Error stopping video: {e}") - self.ids.content_area.remove_widget(self.current_widget) + try: + self.ids.content_area.remove_widget(self.current_widget) + except Exception: + pass self.current_widget = None + # Reset the EOS guard so the next video can advance normally. + self._video_eos_pending = False + self._stop_focus_keeper() + self._stop_video_watchdog() Logger.debug("SignagePlayer: Previous widget removed") + trace("widget_removed") def play_weblink(self, url, duration): """Display a live web page fullscreen using a Chromium kiosk overlay. @@ -1887,11 +2258,27 @@ class SignagePlayer(Widget): Clock.schedule_once(self.next_media, 1) def next_media(self, dt=None): - """Move to next media item""" + """Move to next media item. + + A stale-advance guard prevents duplicate/queued next_media calls from + firing right after a long (blocked) teardown and skipping the media + that was just shown. + """ + trace("next_media_called", + was_index=self.current_index, + paused=self.is_paused) if self.is_paused: Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused") + trace("next_media_BLOCKED_paused") return - + + now = time.monotonic() + if now - self._last_advance_at < 1.0: + trace("next_media_IGNORED_stale", + since_last=round(now - self._last_advance_at, 3)) + return + self._last_advance_at = now + Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})") self.current_index += 1 @@ -2075,8 +2462,10 @@ class SignagePlayer(Widget): def restart_playlist(self): """Restart playlist from beginning""" + trace("restart_playlist", count=len(self.playlist)) if not self.playlist: Logger.warning("SignagePlayer: Cannot restart - playlist is empty") + trace("restart_playlist_EMPTY") return Logger.info("SignagePlayer: Restarting playlist") @@ -2299,6 +2688,7 @@ class SignagePlayer(Widget): def exit_app(self, instance=None): """Exit the application""" Logger.info("SignagePlayer: Exiting application") + self.set_allow_exit(True) App.get_running_app().stop() diff --git a/src/playback_trace.py b/src/playback_trace.py new file mode 100644 index 0000000..9f7397d --- /dev/null +++ b/src/playback_trace.py @@ -0,0 +1,64 @@ +""" +playback_trace.py — Always-on playback transition logger. + +Kivy's log level is forced to 'warning' in main.py / run_win.py, which +suppresses every Logger.info()/Logger.debug() line. That made it impossible +to see why the player skips/crashes at the weblink->image and video->next +transitions. + +This module writes a plain-text trace file (logs/playback_trace.log) with +timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly +what the player is doing. It is thread-safe (a lock guards the append) and +never throws (all failures are swallowed) so it can never break playback. + +Usage: + from playback_trace import trace + trace("play_current_media", index=3, name="foo.jpg", type="image") +""" + +import os +import threading +import time + +_LOCK = threading.Lock() +_LOG_PATH = None +_OPENED = False + + +def _ensure_path(): + global _LOG_PATH, _OPENED + if _OPENED: + return _LOG_PATH + _OPENED = True + try: + # Respect the local data dir the launcher set (same place as logs/). + base = os.environ.get('KIWY_DATA_DIR') or os.getcwd() + log_dir = os.path.join(base, 'logs') + os.makedirs(log_dir, exist_ok=True) + _LOG_PATH = os.path.join(log_dir, 'playback_trace.log') + except Exception: + _LOG_PATH = None + return _LOG_PATH + + +def trace(event, **kwargs): + """Append one line to the playback trace log. + + Args: + event: short event name, e.g. 'next_media', 'eos', 'web_open'. + **kwargs: key=value context, e.g. index=3, name='foo.jpg'. + """ + try: + path = _ensure_path() + if not path: + return + t = time.strftime('%H:%M:%S') + ms = int((time.time() % 1) * 1000) + parts = [f"{t}.{ms:03d}", event] + for k, v in kwargs.items(): + parts.append(f"{k}={v}") + with _LOCK: + with open(path, 'a', encoding='utf-8') as f: + f.write(" ".join(parts) + "\n") + except Exception: + pass # tracing must never break the player diff --git a/src/signage_player.kv b/src/signage_player.kv index 2492c4b..878feba 100644 --- a/src/signage_player.kv +++ b/src/signage_player.kv @@ -598,15 +598,26 @@ font_size: sp(12) on_press: root.restart_player() - # Test Connection Button - Button: - id: test_connection_btn - text: 'Test Server Connection' + # Test Connection + Production Mode Buttons + BoxLayout: + orientation: 'horizontal' size_hint_y: None height: dp(44) - background_color: 0.2, 0.4, 0.8, 1 - font_size: sp(13) - on_press: root.test_connection() + spacing: dp(8) + + Button: + id: test_connection_btn + text: 'Test Server Connection' + background_color: 0.2, 0.4, 0.8, 1 + font_size: sp(13) + on_press: root.test_connection() + + Button: + id: production_mode_btn + text: 'Enable Production' + background_color: 0.4, 0.4, 0.4, 1 # grey = disabled + font_size: sp(13) + on_press: root.toggle_production_mode() # Connection Status Label Label: diff --git a/windows/app_icon.ico b/windows/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9a95f3bc48e8f1808aea66012596c4747e01c049 GIT binary patch literal 43979 zcmafZWmFu&()Qr)?u&=uPJqBdaCdhn5G1%S4#C}BgL`lm4-UcIf;$8Y_T}FD_dDmU zb4IFbW_r5&DeGSc9q50&ClUa-{ww3)_#bbA4gja9000L6 zk1xal00kldfI$AoN4*1p^MA+x2n49<0f3h(07R-NNn@ar{(BR^kd={8`*;0Mjo8SD z|ISo5Z>9hMJ1;9Crs0|GmZWH7V2K}=ce5_!;h)Ht#Gu=nkg9}+6CHXy+GE0cD59^e zCYGr-os%VojUVF92}4iD&jyd+Tx3EWg6d!|rdI!*fe_X`DVkRyG2?0dB72wSOMQ^F zn?NT!xlyjRr@(no1YEY?iBz?~#s|D?yo&fv_r&my!H^Z3g4xZn=yl_U? z(vPYvB*P%XYPXTiUo|Hux8t6B^s$!$ymLVU3X>SYI*)APUsqMk zzm>y7rIS;^D9{_KQ0b1@ehHqyOt{z#)AI#6TzoSAadqF9rPb3Mw$PgJH0nP{FLurK-@VAr2UH~~Q{s^dG|8=! z6SGKUk7p%UE@~atas{V^?~wP(Ha9-&7rj_62z$qVrg9C*$Rcf(2l0TYXemx>B;1Kw zM7$u^yJyE?%VQTTG+%oatx+WRF;ff0b+j!XGtP$}6k&1Vp4 z6!H7{LQbc7`Y@+?cvp2?x@x8-c&&&g6&l_qu(pS_$9|4W?r_viH+Q3a@8_N6?CfQa zjSp{cH!s3YFqQLM{PUR;Y`+a5-xutq#@0eRnJ=EIGkC0>xP|Cp4LmNEd1F`Hqpm({ z^5N|Y!l8e#wqTUJO{dBc_V(@m|?gQ(L{I4^L09&P(YH_%Ksuc)epQrU#In9kcluoH0m*6wnhcS0^iaSFa$ zlrHZ2T3;+U9nCqlx#!TwLtF_Z@WU$wM~0AdN=&St()Kr7PfCy&HrXzo zrovB3-(HvPU7xqB7M5^RH(=@^+maHvhto=1<$;pinvqtCZGR<0CVxd5iM-ts`QPxsIt447Z;z!vZHNkDY9LbFSAn7siiOydAT(87xwPb@-`c< zOs=B>8U8M|=O_YQ^yf3nAeUknNfqx; z7?h>4D@~lWog%ZkC`7@wiqpZQuednS+|0vGwu!XjVIyq>qOiEfl&m=oawpJ*{gjLR zTjsLSL^jyget!$6z3%^coud@aW^(Iaojy^IT`38XiUo_9JK1SF3>P5cxyrr?s#@oD z0?l@6oGVNN6r*^O4OzQMyA90lZucau6H!yWrZ#Rhp>d8Qt zqe5@Vha>HwWp7&q4$Pj+o2;@_&%0*uN4)ox;xY{H{7jY@H8{Cd8>5N)TY5G;uA+Fb zJUy(wb^#K9VypP}C+2_xCmzPSWU^IJ4bzhng6sT0g;z&d+MbtoyT12PMFw-≶Wi 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ðVz%=;5Pezc{ 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 literal 0 HcmV?d00001 diff --git a/windows/build.spec b/windows/build.spec index 8f73310..4269539 100644 --- a/windows/build.spec +++ b/windows/build.spec @@ -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 ------------------------- diff --git a/windows/development-track.md b/windows/development-track.md index 06dceb6..5064014 100644 --- a/windows/development-track.md +++ b/windows/development-track.md @@ -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** diff --git a/windows/run_win.py b/windows/run_win.py index 7852881..7b6ecce 100644 --- a/windows/run_win.py +++ b/windows/run_win.py @@ -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 diff --git a/windows/version_info.txt b/windows/version_info.txt new file mode 100644 index 0000000..aab1552 --- /dev/null +++ b/windows/version_info.txt @@ -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])]) + ] +)