diff --git a/PLAYER_WEBLINK_INTEGRATION.md b/PLAYER_WEBLINK_INTEGRATION.md index a107df0..9ba7b53 100644 --- a/PLAYER_WEBLINK_INTEGRATION.md +++ b/PLAYER_WEBLINK_INTEGRATION.md @@ -6,9 +6,10 @@ playlist item type (display a live web page / URL instead of an uploaded media file). > **Status: implemented.** The player supports `weblink` items on both -> Raspberry Pi (`chromium` subprocess) and Windows (embedded CEF with a -> Chrome/Edge subprocess fallback). Sections 1–4 describe the original design -> plan; section 6 documents the shipped architecture and the interaction model. +> Raspberry Pi (`chromium` subprocess) and Windows (embedded **WebView2**, with +> the CEF and Chrome/Edge subprocess engines as fallbacks). Sections 1–4 +> describe the original design plan; section 6 documents the shipped +> architecture and the interaction model. --- @@ -256,18 +257,56 @@ platform: | Piece | Responsibility | |--------------------------|----------------| | `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. | -| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. | -| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`, Windows `chrome.exe`/`msedge.exe`). | +| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. | +| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`; on Windows the Chrome/Edge fallback). | | `InteractionWatcher` | Decides when the item is finished (see the interaction model below). | -| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded CEF). | +| `WebInputSources` | Reads `/dev/input/event*` (Linux) and does a pointer-position tap (Windows, needed for embedded engines). | +| `webview2_browser.py` | **Windows, preferred**: embeds WebView2 as a child HWND of the Kivy window. | +| `webview2_runtime.py` | **Windows**: detects the WebView2 Runtime and installs it silently when missing. | Platform wrappers inject their engines through `SignagePlayer.weblink_adapter_factory`: * **Raspberry Pi / Linux** — built-in Chromium subprocess adapter. -* **Windows** (`windows/run_win.py`) — embedded CEF first (`cef_browser.py`, - renders inside the Kivy window: no z-order fights, no subprocess), then the - Chrome/Edge subprocess adapter as fallback. +* **Windows** (`windows/run_win.py`) — engines are tried in this order: + + | Order | Engine | Renders | Notes | + |-------|--------|---------|-------| + | 1 | **WebView2** (`webview2_browser.py`) | child window **inside** Kivy | Preferred. No subprocess, so no background/z-order/hand-off/leak problems. | + | 2 | CEF (`cef_browser.py`) | child window inside Kivy | Dormant: `cefpython3` has no wheels past Python 3.9. | + | 3 | Chrome/Edge subprocess | separate window | Last resort only; retains the old drawbacks. | + +`WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags +without duplicating `launch()` — the Chrome adapter uses it for +`--user-data-dir` + `--kiosk`. + +> **Do not give the factory a class-level `None` default combined with an +> unconditional instance assignment.** `SignagePlayer.__init__` originally set +> `self.weblink_adapter_factory = None`, which shadowed the class attribute the +> Windows wrapper installs — so the platform adapters were silently ignored and +> every weblink fell back to the generic adapter and failed. It now only sets +> the instance attribute when the class attribute is absent. + +### 6.2 Windows: WebView2 Runtime + +WebView2 is two separate things, and they ship differently: + +* the **SDK** (`Microsoft.Web.WebView2.Core.dll`, `WebView2Loader.dll`) — the + API surface, bundled in the exe from `windows\webview2_sdk\` (~860 KB); +* the **Runtime** (`msedgewebview2.exe`) — the actual Chromium engine, shipped + by Microsoft and **verified/installed at start-up** by + `windows\webview2_runtime.py`. + +If the Runtime is absent the player runs an installer silently +(`/silent /install`) and **unelevated**, which produces a *per-user* install and +therefore never raises a UAC prompt on the signage display. A ~1.7 MB online +bootstrapper is bundled by default; a ~203 MB offline standalone installer can +be bundled instead (see `windows\webview2_runtime\download_runtime_installers.ps1`) +for machines with no internet. + +Install success is decided by **re-reading the installed version**, not by the +installer exit code — Edge Update returns a non-zero HRESULT (e.g. +`-2147219416`) when the Runtime is already current, which is not a failure. ### 6.1 Interaction model — web links are not passive media diff --git a/src/main.py b/src/main.py index 92274d8..11d8825 100644 --- a/src/main.py +++ b/src/main.py @@ -9,6 +9,8 @@ import os import json import platform import signal +import subprocess +import sys import threading import time import asyncio @@ -91,6 +93,7 @@ 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 +from video_safety import suppress_kivy_video_blocking_unload # bound Kivy's blocking video join from weblink_session import ( WeblinkSession, WeblinkSettings, @@ -627,12 +630,19 @@ class ExitPasswordPopup(Popup): Clock.schedule_once(lambda dt: self.dismiss(), 1) class SettingsPopup(Popup): - def __init__(self, player_instance, was_paused=False, **kwargs): + def __init__(self, player_instance, was_paused=False, first_run=False, **kwargs): super(SettingsPopup, self).__init__(**kwargs) self.player = player_instance self.was_paused = was_paused + self.first_run = bool(first_run) self.keyboard_widget = None - + + # First-run setup is the ONLY thing on screen and there is no playlist + # yet, so the popup must not be dismissable into a blank player. + if self.first_run: + self.auto_dismiss = False + self.title = 'Player Setup - Not Configured' + # Cancel all scheduled cursor/control hide events try: if self.player.controls_timer: @@ -649,15 +659,22 @@ class SettingsPopup(Popup): except: pass - # Populate current values - self.ids.server_input.text = self.player.config.get('server_ip', 'localhost') - self.ids.port_input.text = str(self.player.config.get('port', '')) - self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player') - self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567') + # Populate current values. Fields are blank on a fresh install, so the + # hint text tells the operator what to enter. + self.ids.server_input.text = self.player.config.get('server_ip', '') + self.ids.port_input.text = str(self.player.config.get('port', '') or '') + self.ids.screen_input.text = self.player.config.get('screen_name', '') + self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '') self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape') self.ids.touch_input.text = self.player.config.get('touch', 'True') - self.ids.resolution_input.text = self.player.config.get('max_resolution', 'auto') + self.ids.resolution_input.text = self.player.config.get('max_resolution', '1920x1080') self.ids.edit_enabled_checkbox.active = self.player.config.get('edit_feature_enabled', True) + + if self.first_run: + Logger.info( + "SettingsPopup: First-run setup opened (server_ip/screen_name/" + "quickconnect_key must be filled in)" + ) # Update status info self.ids.playlist_info.text = f'Playlist: v{self.player.playlist_version}' @@ -711,6 +728,11 @@ class SettingsPopup(Popup): """Handle popup dismissal - resume playback and restart cursor hide timer""" # Hide and remove keyboard self.hide_keyboard() + if self.first_run: + # Nothing was playing: there is no playlist to resume, and + # scheduling an advance here would race the first sync kicked off + # by on_first_run_config_saved(). + return # Resume playback and re-arm the media advance timer self.player.resume_after_popup(self.was_paused) @@ -919,27 +941,122 @@ class SettingsPopup(Popup): Clock.schedule_once(lambda dt: popup.dismiss(), 2) def save_and_close(self): - """Save configuration and close popup""" + """Save configuration and close popup. + + During first-run setup the play/pause guard does not apply (there is no + playlist yet) and the player is told to start once the details are in. + """ # Update config - self.player.config['server_ip'] = self.ids.server_input.text + self.player.config['server_ip'] = self.ids.server_input.text.strip() self.player.config['port'] = self.ids.port_input.text.strip() - self.player.config['screen_name'] = self.ids.screen_input.text - self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text + self.player.config['screen_name'] = self.ids.screen_input.text.strip() + self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text.strip() self.player.config['orientation'] = self.ids.orientation_input.text self.player.config['touch'] = self.ids.touch_input.text self.player.config['max_resolution'] = self.ids.resolution_input.text self.player.config['edit_feature_enabled'] = self.ids.edit_enabled_checkbox.active - + + # First-run validation: refuse to "configure" the player with blanks, + # otherwise it would be marked configured and then fail on every sync. + if self.first_run: + missing = [ + label for label, key in ( + ('Server IP', 'server_ip'), + ('Player Name', 'screen_name'), + ('Quick Connect Key', 'quickconnect_key'), + ) + if not str(self.player.config.get(key, '') or '').strip() + ] + if missing: + self._show_temp_message( + 'Required: ' + ', '.join(missing), (1, 0.7, 0, 1) + ) + return + # A brand-new install has no reason to keep HTTPS verification on. + self.player.config.setdefault('use_https', False) + self.player.config.setdefault('verify_ssl', False) + # Save to file self.player.save_config() # Notify user that resolution change requires restart if self.ids.resolution_input.text != self.player.config.get('max_resolution', 'auto'): Logger.info("SettingsPopup: Resolution changed - restart required") + + was_first_run = self.first_run # Close popup self.dismiss() + if was_first_run: + self.player.on_first_run_config_saved() + + +# ── First-run configuration ────────────────────────────────────────── +# The player is shipped WITHOUT any server credentials: `app_config.json` is +# not bundled into the exe (see windows/build.spec). On first start there is +# nothing to connect to, so the player shows a notice after the splash video +# and then opens Settings automatically. +# +# These are the values the player cannot work without, plus the placeholder +# values a fresh install used to be given. A config holding only placeholders +# counts as UNCONFIGURED, so a machine that has never been set up runs the +# first-run flow instead of silently trying to reach "localhost". +CONFIG_REQUIRED_KEYS = ('server_ip', 'screen_name', 'quickconnect_key') + +CONFIG_PLACEHOLDER_VALUES = { + 'server_ip': {'', 'localhost', '127.0.0.1'}, + 'screen_name': {'', 'kivy-player'}, + 'quickconnect_key': {'', '1234567'}, +} + +#: In-memory starting point when no usable config file exists. Deliberately +#: has EMPTY credentials so `config_is_configured()` reports False. +DEFAULT_CONFIG = { + 'server_ip': '', + 'port': '8080', + 'screen_name': '', + 'quickconnect_key': '', + 'orientation': 'Landscape', + 'touch': 'True', + 'max_resolution': '1920x1080', + 'edit_feature_enabled': True, + 'use_https': False, + 'verify_ssl': False, + 'production_mode': False, + 'weblink': { + 'engine': 'auto', + 'interaction_postpone': 10, + 'interaction_debounce': 0.5, + 'interaction_grace': 5.0, + 'max_dwell_factor': 6.0, + 'min_max_dwell': 300, + 'launch_timeout': 15, + 'prewarm': True, + }, +} + +#: Seconds the "not configured" notice stays up before Settings opens. +SETUP_NOTICE_SECONDS = 5 + + +def config_is_configured(config): + """True when ``config`` has enough real values to talk to a server. + + Missing file, empty file, unparseable JSON, missing keys and leftover + placeholder values all mean "not configured" — that is what triggers the + first-run setup screen instead of a playlist attempt. + """ + if not isinstance(config, dict) or not config: + return False + for key in CONFIG_REQUIRED_KEYS: + value = str(config.get(key, '') or '').strip() + if not value: + return False + if value.lower() in CONFIG_PLACEHOLDER_VALUES.get(key, set()): + return False + return True + class SignagePlayer(Widget): from kivy.properties import StringProperty @@ -968,8 +1085,16 @@ class SignagePlayer(Widget): # watching and teardown for one weblink item at a time. Platform wrappers # (e.g. windows/run_win.py) inject their adapters via # `weblink_adapter_factory` before playback starts. + # + # CRITICAL: the factory is injected as a *class* attribute, so do NOT + # assign None unconditionally here. An instance attribute would shadow + # it, play_weblink() would silently fall back to the generic adapter, + # and on Windows that adapter's find_browser() (shutil.which) finds no + # browser because Chrome/Edge are not on PATH — so every weblink failed + # with "launch() returned False" and the item was skipped. self._weblink_session = None - self.weblink_adapter_factory = None + if not callable(getattr(type(self), 'weblink_adapter_factory', None)): + self.weblink_adapter_factory = None self.is_playing = False self.is_paused = False self.auto_resume_event = None # Track scheduled auto-resume @@ -1238,31 +1363,141 @@ class SignagePlayer(Widget): # Start media playback Clock.schedule_interval(self.check_playlist_and_play, 30) # Check every 30 seconds - def load_config(self): - """Load configuration from file""" - Logger.debug("SignagePlayer: load_config() starting...") + def requires_setup(self): + """True when the player has no usable server configuration.""" + return not getattr(self, '_configured', config_is_configured(self.config)) + + def on_intro_finished(self): + """Splash video has ended — continue with either setup or playback. + + Single decision point for "intro done", so the first-run branch and the + normal branch cannot drift apart. + """ + self.intro_played = True + if self.requires_setup(): + Logger.warning( + "SignagePlayer: No server configuration - showing setup notice" + ) + self.show_setup_required_notice() + return + # Normal start: load whatever playlist is cached and begin playing. + self.check_playlist_and_play(None) + + def show_setup_required_notice(self): + """Show the "not configured" notice, then open Settings automatically. + + The player is shipped without credentials, so this is the expected + first-run experience on a brand-new .exe: tell the operator what is + missing, wait SETUP_NOTICE_SECONDS, then open the settings screen so + they can enter the server details. + """ + trace('setup_required_shown') try: - if os.path.exists(self.config_file): + self.ids.status_label.text = ( + 'Player is not configured\n\n' + 'No server settings found. Opening setup...' + ) + self.ids.status_label.opacity = 1 + except Exception: + pass + + self._setup_notice_event = Clock.schedule_once( + lambda dt: self._open_first_run_settings(), SETUP_NOTICE_SECONDS + ) + + def _open_first_run_settings(self): + """Open Settings for first-run configuration.""" + trace('setup_opening_settings') + try: + self.ids.status_label.opacity = 0 + except Exception: + pass + popup = SettingsPopup(player_instance=self, first_run=True) + popup.open() + + def on_first_run_config_saved(self): + """Called when Settings was used to configure the player. + + Applies the new values immediately and starts playback, so the operator + does not have to restart the .exe after entering the server details. + """ + self._configured = config_is_configured(self.config) + if not self._configured: + Logger.warning( + "SignagePlayer: Settings saved but the player is still not " + "configured - setup will be offered again" + ) + self.show_setup_required_notice() + return + + trace('setup_completed') + Logger.info("SignagePlayer: First-run configuration saved - starting playback") + try: + self.ids.status_label.text = 'Configuration saved - connecting...' + self.ids.status_label.opacity = 1 + except Exception: + pass + + # Re-arm the pieces that depend on the server details. + self.start_network_monitoring() + + # Pull the first playlist in the background, then play it. + def _fetch_and_play(dt): + try: + updated = update_playlist_if_needed( + self.config, self.playlists_dir, self.media_dir + ) + if updated: + Logger.info("SignagePlayer: Playlist fetched after setup") + except Exception as exc: + Logger.error(f"SignagePlayer: Playlist fetch after setup failed: {exc}") + self.load_playlist() + self.is_playing = False + self.is_paused = False + self.start_playback() + + threading.Thread(target=_fetch_and_play, args=(None,), daemon=True).start() + + def load_config(self): + """Load configuration from file. + + A missing/empty/unreadable file is NOT an error and is NOT written + back: the player starts with empty credentials and the first-run setup + flow asks the operator to fill them in. Writing a placeholder file here + would defeat that (and used to plant `localhost` as a fake server). + """ + Logger.debug("SignagePlayer: load_config() starting...") + self.config = dict(DEFAULT_CONFIG) + self._config_file_existed = os.path.exists(self.config_file) + try: + if self._config_file_existed: with open(self.config_file, 'r') as f: - self.config = json.load(f) - Logger.info(f"SignagePlayer: Configuration loaded from {self.config_file}") + loaded = json.load(f) + if isinstance(loaded, dict) and loaded: + # Keep defaults for anything the file omits. + self.config.update(loaded) + Logger.info( + f"SignagePlayer: Configuration loaded from {self.config_file}" + ) + else: + Logger.warning( + "SignagePlayer: Configuration file is empty or not a JSON " + "object - treating the player as unconfigured" + ) else: - # Create default configuration with HTTPS support - self.config = { - "server_ip": "localhost", - "port": "443", - "screen_name": "kivy-player", - "quickconnect_key": "1234567", - "max_resolution": "auto", - "use_https": True, - "verify_ssl": True, - "production_mode": False - } - self.save_config() - Logger.info("SignagePlayer: Created default configuration with HTTPS enabled") + Logger.warning( + f"SignagePlayer: No configuration file at {self.config_file} " + "- first-run setup will be shown" + ) except Exception as e: Logger.error(f"SignagePlayer: Error loading config: {e}") - self.show_error(f"Failed to load configuration: {e}") + self.config = dict(DEFAULT_CONFIG) + + self._configured = config_is_configured(self.config) + Logger.info( + "SignagePlayer: Configuration status: " + + ("configured" if self._configured else "NOT configured (setup required)") + ) def save_config(self): """Save configuration to file""" @@ -1380,14 +1615,18 @@ class SignagePlayer(Widget): if not os.path.exists(intro_path): Logger.warning(f"SignagePlayer: Intro video not found at {intro_path}") - # Skip intro and load playlist - self.intro_played = True - Clock.schedule_once(self.check_playlist_and_play, 0.1) + # No splash to show: go straight to setup or playback. + self.on_intro_finished() return try: Logger.info("SignagePlayer: Playing intro video...") self.ids.status_label.opacity = 0 # Hide status label + + # Same blocking-join hazard as playlist videos: Kivy's on_state + # teardown joins the ffpyplayer decode thread on the calling + # thread. Install the bound join before the widget exists. + suppress_kivy_video_blocking_unload() # Create video widget for intro intro_video = Video( @@ -1407,23 +1646,29 @@ class SignagePlayer(Widget): # Mark intro as played before removing video self.intro_played = True - # Stop and unload the video properly - try: - instance.state = 'stop' - instance.unload() - except Exception as e: - Logger.debug(f"SignagePlayer: Could not unload intro video: {e}") - - # Remove intro video + # Do NOT call instance.state = 'stop' / instance.unload() + # here: this runs during the Kivy MainThread dispatch of + # state, and Kivy's Video.unload() joins the ffpyplayer + # decode thread on the CALLING thread — a blocking call + # that froze the UI for 0.4s..100s+ and, when the thread + # never exited, hung the whole player. Detach the widget + # and let the normal video-teardown worker stop it. try: if intro_video in self.ids.content_area.children: self.ids.content_area.remove_widget(intro_video) except Exception as e: Logger.warning(f"SignagePlayer: Error removing intro video widget: {e}") + + threading.Thread( + target=self._teardown_video_async, + args=(intro_video,), + daemon=True, + name='intro-video-teardown', + ).start() # Start normal playlist immediately to reduce white screen Logger.debug("SignagePlayer: Triggering playlist check after intro") - self.check_playlist_and_play(None) + self.on_intro_finished() except Exception as e: Logger.error(f"SignagePlayer: Error in intro end callback: {e}") import traceback @@ -1438,9 +1683,8 @@ class SignagePlayer(Widget): Logger.error(f"SignagePlayer: Error playing intro video: {e}") import traceback Logger.error(f"SignagePlayer: Traceback: {traceback.format_exc()}") - # Skip intro and load playlist - self.intro_played = True - Clock.schedule_once(self.check_playlist_and_play, 0.1) + # Skip intro and continue with setup or playback + self.on_intro_finished() def check_playlist_and_play(self, dt): """Check for playlist updates and ensure playback is running""" @@ -1448,6 +1692,11 @@ class SignagePlayer(Widget): if not self.intro_played: return + # An unconfigured player has no server to sync from; leave the setup + # flow in charge (otherwise the 30s timer would fight the notice). + if self.requires_setup(): + return + if not self.playlist: self.load_playlist() @@ -1663,6 +1912,39 @@ class SignagePlayer(Widget): self.show_error(f"Error playing media: {e}") self._skip_to_next_media() + def _video_has_audio(self, video_path): + """True when the file actually contains an audio stream. + + Needed because ffpyplayer initialises SDL2_mixer from the FIRST audio + file it opens and reuses those parameters. Handing it a video with NO + audio track (integer division of rate/channels by zero internally) + crashes the process with an access violation in SDL2_mixer.dll + (0xc0000005) — observed when a silent 4K clip entered the playlist + after an AAC stereo clip had already been played. + + Uses ffprobe (bundled next to the app) and fails SAFE: if we cannot + determine the streams, we report True and let the normal path proceed. + """ + try: + ffprobe = os.path.join(os.path.dirname(sys.executable), '_internal', 'ffprobe.exe') + if not os.path.exists(ffprobe): + # Development run: fall back to PATH / the ffpyplayer bundle. + import shutil as _shutil + ffprobe = _shutil.which('ffprobe') + if not ffprobe: + return True # cannot tell -> assume it has audio + + result = subprocess.run( + [ffprobe, '-v', 'error', '-select_streams', 'a', + '-show_entries', 'stream=index', '-of', 'csv=p=0', video_path], + capture_output=True, text=True, timeout=10, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), + ) + return bool((result.stdout or '').strip()) + except Exception as exc: + Logger.debug(f"SignagePlayer: audio probe failed for {video_path}: {exc}") + return True # fail safe: let Kivy try + def play_video(self, video_path, duration, muted=False): """Play a video file using Kivy's Video widget with optimizations. @@ -1682,9 +1964,29 @@ class SignagePlayer(Widget): self.consecutive_errors += 1 self._skip_to_next_media() return - + + # A video with no audio stream must never be handed to ffpyplayer + # with sound enabled: SDL2_mixer access-violates on a silent track. + # Signage has no audio output anyway, so mute it. + if not muted and not self._video_has_audio(video_path): + Logger.info( + "SignagePlayer: Video has no audio track - forcing mute " + "(avoids an SDL2_mixer crash)" + ) + muted = True + Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") - + + # Bound the blocking join() Kivy's VideoFFPy performs on the + # CALLING thread during unload. Kivy's own on_eos handler sets + # state='stop' (which joins the ffpyplayer decode thread) while the + # event is being dispatched — i.e. on the Kivy main thread. When + # that thread is slow to exit, the join parks the UI thread and + # Windows declares the app hung (AppHangB1, observed after ~30-45 + # minutes of looping). Must run BEFORE the widget is constructed: + # the decode thread is created during play(). See src/video_safety.py. + suppress_kivy_video_blocking_unload() + # Create Video widget with optimized settings for smooth playback. # Apply the server's audio:off/muted flag via 'volume' (0.0=mute). self._video_source = video_path diff --git a/src/weblink_session.py b/src/weblink_session.py index a5720f9..438da90 100644 --- a/src/weblink_session.py +++ b/src/weblink_session.py @@ -223,6 +223,13 @@ class WeblinkAdapter: #: The session then skips the "browser window appeared" requirement. embedded = False + #: Set by adapters that render in-window but can still *prove* the page + #: appeared (WebView2). Without this, an embedded engine is trusted + #: blindly, so a page that fails to load (unreachable host on a closed + #: network, DNS failure, 404) would sit on screen for the whole slot + #: instead of being skipped. + can_verify_visibility = False + #: The launched process, when the engine is subprocess based. The session #: mirrors this onto the player's historic ``_weblink_proc`` attribute. process = None @@ -705,6 +712,17 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): # Nothing generic to do; the platform layer hooks in here (overlay). pass + def extra_launch_args(self): + """Extra flags appended to the browser command line. + + Subclasses override this instead of duplicating ``launch()``. The + Windows adapter uses it to inject a dedicated ``--user-data-dir``, + which is mandatory there: without it Chrome/Edge hands the URL to an + already-running instance, the process we launched exits immediately + and the weblink never becomes visible. + """ + return () + def launch(self, url, width, height): browser = self._browser or self.find_browser() if not browser: @@ -735,6 +753,7 @@ class ChromiumSubprocessAdapter(WeblinkAdapter): '--force-device-scale-factor=1', ] args += self._extra_flags + args += [str(arg) for arg in self.extra_launch_args()] self._proc = subprocess.Popen(args) return True @@ -958,6 +977,13 @@ class WeblinkSession: with self._lock: if self._watcher is not None: self._watcher.stop() + # An embedded engine normally cannot be verified (CEF), but some can + # (WebView2) — for those the visibility wait must still run, + # otherwise a page that never loads is shown as a blank screen for + # the whole duration instead of being skipped. + verify = adapter.wait_visible if ( + not adapter.embedded or adapter.can_verify_visibility + ) else None self._watcher = InteractionWatcher( duration=duration, alive_check=adapter.is_alive, @@ -972,7 +998,7 @@ class WeblinkSession: interaction_debounce=self.settings.interaction_debounce, interaction_grace=self.settings.interaction_grace, embedded=adapter.embedded, - wait_visible=None if adapter.embedded else adapter.wait_visible, + wait_visible=verify, visible_timeout=self.settings.launch_timeout, launched_at=launched_at, ) diff --git a/windows/build.spec b/windows/build.spec index 0aff137..41ba522 100644 --- a/windows/build.spec +++ b/windows/build.spec @@ -103,6 +103,8 @@ hidden_imports = [ 'tempfile', # Windows-specific 'cef_browser', + 'webview2_browser', + 'webview2_runtime', 'win32gui', 'win32con', # Unified web-link controller (launch / verified visibility / interaction @@ -139,11 +141,60 @@ for item in RESOURCES_DIR.iterdir(): target_dir = 'config/resources' resources_data.append((str(item), target_dir)) -# Config directory (app_config.json) +# Config directory. +# +# app_config.json is deliberately NOT bundled. Including it shipped the +# developer's own server_ip / screen_name inside the exe, so a fresh install +# silently connected to the wrong server (or to a placeholder) instead of +# asking the operator. The player now starts unconfigured, shows a notice after +# the splash video and opens Settings to collect the real values, which are +# then saved next to the .exe. config_data = [] config_file = CONFIG_DIR / 'app_config.json' if config_file.exists(): - config_data.append((str(config_file), 'config')) + print("[spec] app_config.json is NOT bundled (first-run setup collects it)") + +# --- Bundled web engines --------------------------------------------- +# Embedded WebView2 (Edge) SDK: the managed assembly plus the native loader +# DLL. The *runtime* itself is a Microsoft-shipped evergreen component and is +# deliberately NOT bundled (that is the point of using WebView2 — no ~150 MB +# Chromium payload inside our exe). +webview2_data = [] +webview2_sdk = BUILD_DIR / 'webview2_sdk' +if webview2_sdk.is_dir(): + for item in webview2_sdk.iterdir(): + if item.is_file(): + webview2_data.append((str(item), 'webview2_sdk')) + print(f"[spec] Bundling {len(webview2_data)} WebView2 SDK file(s) from {webview2_sdk}") + +# WebView2 Runtime installer, so a machine that ships WITHOUT the Runtime can +# install it on first start (see windows/webview2_runtime.py). +# +# Only the small bootstrapper (~1.7 MB) is bundled by default; it downloads the +# Runtime from Microsoft. Dropping the ~203 MB offline standalone installer +# into windows/webview2_runtime/ bundles it too, which is what you want for +# machines with no internet — but it triples the exe size, so it is opt-in. +webview2_runtime = BUILD_DIR / 'webview2_runtime' +_bootstrap = webview2_runtime / 'MicrosoftEdgeWebview2Setup.exe' +_standalone = webview2_runtime / 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' +if _bootstrap.is_file(): + webview2_data.append((str(_bootstrap), 'webview2_runtime')) + print(f"[spec] Bundling WebView2 Runtime bootstrapper ({_bootstrap.stat().st_size / 1024 / 1024:.1f} MB)") +if _standalone.is_file(): + webview2_data.append((str(_standalone), 'webview2_runtime')) + print(f"[spec] Bundling WebView2 offline standalone installer " + f"({_standalone.stat().st_size / 1024 / 1024:.0f} MB) — exe will be much larger") +if not _bootstrap.is_file() and not _standalone.is_file(): + print("=" * 70) + print("WARNING: no WebView2 Runtime installer in windows/webview2_runtime/.") + print("Machines without the Runtime cannot show web links (they fall back") + print("to the Chrome/Edge subprocess engine).") + print("=" * 70) +else: + print("=" * 70) + print("WARNING: windows/webview2_sdk/ not found.") + print("Web links will fall back to the Chrome/Edge subprocess engine.") + print("=" * 70) # Source files - .kv file kv_file = SRC_DIR / 'signage_player.kv' @@ -151,8 +202,19 @@ kv_data = [] if kv_file.exists(): kv_data.append((str(kv_file), '.')) -# Bundle the entire src directory as a tree -source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini']) +# Bundle the entire src directory as a tree. +# +# EXCLUDE player_auth.json: it holds LIVE credentials (auth_code, player_id, +# server_url). Bundling it means the frozen app starts up in _internal/ and +# loads that snapshot as its auth state — so a freshly built exe boots +# "already authenticated" against whatever server the file happened to name, +# and plays a stale playlist. Auth must be created at runtime in the data dir +# next to the .exe (see run_win.py `_patch_auth_paths`). +source_tree = Tree( + str(SRC_DIR), + prefix='', + excludes=['*.pyc', '__pycache__', '*.ini', 'player_auth.json'], +) # --- Collect binary DLLs from kivy_deps and ffpyplayer ---------------- import importlib.util @@ -250,7 +312,7 @@ a = Analysis( ['run_win.py'], # Entry point (relative to this spec) pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules binaries=_all_binaries, - datas=resources_data + config_data + kv_data, + datas=resources_data + config_data + kv_data + webview2_data, hiddenimports=hidden_imports, hookspath=[], hooksconfig={}, diff --git a/windows/requirements_win.txt b/windows/requirements_win.txt index da31bdd..b0145f6 100644 --- a/windows/requirements_win.txt +++ b/windows/requirements_win.txt @@ -23,6 +23,18 @@ bcrypt>=4.2.0,<5.0.0 # PyInstaller for building the .exe pyinstaller>=6.0 +# --- Embedded web engine (web links) --- +# pythonnet lets Python drive the WebView2 .NET SDK. WebView2 renders INSIDE +# the Kivy window as a child window, which is what removed the old subprocess +# browser bugs (window opening behind the player, instant hand-off exit, +# z-order/focus fights, leaked chrome.exe/msedge.exe processes). +# The WebView2 *runtime* is a free, Microsoft-shipped evergreen component and +# is intentionally NOT bundled; the small SDK DLLs live in windows/webview2_sdk/ +# and are added to the exe by build.spec. +# Without pythonnet the player silently falls back to the Chrome/Edge +# subprocess engine, so weblinks still work but with the old drawbacks. +pythonnet>=3.0.3 + # --- Windows-specific Libraries --- # cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge) # Installed separately because it's a large package (69 MB): diff --git a/windows/test_webview2_embed.py b/windows/test_webview2_embed.py new file mode 100644 index 0000000..b06bea8 --- /dev/null +++ b/windows/test_webview2_embed.py @@ -0,0 +1,169 @@ +"""Standalone harness for windows/webview2_browser.py — no Kivy, no player. + +Creates a plain Win32 window, embeds WebView2 in it via the same +WebView2Browser class the player uses, navigates to a page, checks that the +page actually becomes visible, then resizes and tears down. + +Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_embed.py +Exit code 0 = embedded engine works. +""" + +import ctypes +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +URL = os.environ.get('KIWY_TEST_URL', 'https://example.com/') + +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 + +WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_int64, + ctypes.c_void_p, # HWND + ctypes.c_uint, # UINT msg + ctypes.c_void_p, # WPARAM + ctypes.c_void_p, # LPARAM +) + +_messages = [] + + +@WNDPROC +def _wnd_proc(hwnd, msg, wparam, lparam): + _messages.append(msg) + if msg == 0x0002: # WM_DESTROY + user32.PostQuitMessage(0) + return 0 + user32.DefWindowProcW.restype = ctypes.c_int64 + user32.DefWindowProcW.argtypes = [ + ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, + ] + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + +def _make_window(width=1280, height=720): + """Register a class and create a visible top-level window.""" + hinstance = kernel32.GetModuleHandleW(None) + class_name = 'KiwyWebView2Test' + + class WNDCLASSEX(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_uint), + ('style', ctypes.c_uint), + ('lpfnWndProc', WNDPROC), + ('cbClsExtra', ctypes.c_int), + ('cbWndExtra', ctypes.c_int), + ('hInstance', ctypes.c_void_p), + ('hIcon', ctypes.c_void_p), + ('hCursor', ctypes.c_void_p), + ('hbrBackground', ctypes.c_void_p), + ('lpszMenuName', ctypes.c_wchar_p), + ('lpszClassName', ctypes.c_wchar_p), + ('hIconSm', ctypes.c_void_p), + ] + + wc = WNDCLASSEX() + wc.cbSize = ctypes.sizeof(WNDCLASSEX) + wc.style = 0x0002 | 0x0001 # CS_HREDRAW | CS_VREDRAW + wc.lpfnWndProc = _wnd_proc + wc.hInstance = hinstance + wc.hbrBackground = ctypes.c_void_p(6) # COLOR_WINDOW+1 + wc.lpszClassName = class_name + user32.RegisterClassExW(ctypes.byref(wc)) + + hwnd = user32.CreateWindowExW( + 0, + class_name, + 'Kiwy WebView2 Embed Test', + 0x00CF0000 | 0x10000000, # WS_OVERLAPPEDWINDOW | WS_VISIBLE + 100, 100, width, height, + 0, 0, hinstance, 0, + ) + if not hwnd: + raise RuntimeError(f'CreateWindowExW failed (err={kernel32.GetLastError()})') + user32.UpdateWindow(hwnd) + return hwnd + + +def _pump(seconds): + """Pump Win32 messages — WebView2 needs this to deliver its callbacks.""" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + msg = ctypes.wintypes.MSG() if hasattr(ctypes, 'wintypes') else None + import ctypes.wintypes as wt + + msg = wt.MSG() + while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + time.sleep(0.02) + + +def main(): + print('=' * 68) + print(' WebView2 embedded-engine test') + print('=' * 68) + + from webview2_browser import WebView2Browser + + print(f'SDK dir : {WebView2Browser.__module__}') + available = WebView2Browser.is_available() + print(f'available: {available}') + if not available: + print(f'REASON: {WebView2Browser._import_error}') + return 1 + + hwnd = _make_window() + print(f'window : hwnd=0x{hwnd:x}') + + browser = WebView2Browser(hwnd_provider=lambda: hwnd) + started = time.monotonic() + ok = browser.show(URL) + print(f'show() : {ok}') + if not ok: + print(f'FAILED : {browser.failed_reason}') + return 1 + + # Drive the Kivy-style Clock poll manually while pumping messages. + visible = False + while time.monotonic() - started < 25: + browser._tick(0) # consume the async task + _pump(0.1) + if browser.failed_reason: + print(f'FAILED : {browser.failed_reason}') + return 1 + if browser.is_showing(): + visible = True + break + print(f'visible : {visible} after {time.monotonic() - started:.1f}s') + if not visible: + print('FAILED : page never became visible') + return 1 + + # Resize to the signage resolution and confirm it is applied. + browser.resize(1920, 1080) + _pump(1.0) + print(f'resized : 1920x1080 (bounds={browser._size})') + + # Hide, then re-show to prove the controller survives a transition. + browser.hide() + _pump(0.5) + print(f'after hide -> is_showing={browser.is_showing()}') + browser.show(URL) + _pump(2.0) + print(f'after re-show -> is_showing={browser.is_showing()}') + + browser.shutdown() + print('shutdown: ok') + print('=' * 68) + print(' RESULT: PASS') + print('=' * 68) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/windows/test_webview2_navigation.py b/windows/test_webview2_navigation.py new file mode 100644 index 0000000..bac7290 --- /dev/null +++ b/windows/test_webview2_navigation.py @@ -0,0 +1,186 @@ +"""Does pythonnet fire NavigationCompleted for a real page load? + +This validates the mechanism the player relies on to tell "page loaded" apart +from "page failed" (e.g. unreachable host on a closed network). If the event +does not fire, the player cannot detect a failed weblink and would show +Chromium's error page for the full slot. + +Checks three things: + 1. the delegate can be constructed and subscribed, + 2. it fires for a GOOD page -> IsSuccess True, + 3. it fires for a BAD page -> IsSuccess False. + +Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_navigation.py +Exit code 0 = PASS. +""" + +import ctypes +import http.server +import socketserver +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +PORT = 18766 +PAGE = '

KIWY-NAV-OK

' + + +class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = PAGE.encode() + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +def _start_server(): + socketserver.TCPServer.allow_reuse_address = True + httpd = socketserver.TCPServer(('127.0.0.1', PORT), _Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd + + +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 +WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_int64, ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p +) + + +@WNDPROC +def _wnd_proc(hwnd, msg, wparam, lparam): + if msg == 0x0002: + user32.PostQuitMessage(0) + return 0 + user32.DefWindowProcW.restype = ctypes.c_int64 + user32.DefWindowProcW.argtypes = [ + ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, + ] + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + +def _make_window(width=1024, height=768): + hinstance = kernel32.GetModuleHandleW(None) + name = 'KiwyNavTest' + + class WNDCLASSEX(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_uint), ('style', ctypes.c_uint), + ('lpfnWndProc', WNDPROC), ('cbClsExtra', ctypes.c_int), + ('cbWndExtra', ctypes.c_int), ('hInstance', ctypes.c_void_p), + ('hIcon', ctypes.c_void_p), ('hCursor', ctypes.c_void_p), + ('hbrBackground', ctypes.c_void_p), ('lpszMenuName', ctypes.c_wchar_p), + ('lpszClassName', ctypes.c_wchar_p), ('hIconSm', ctypes.c_void_p), + ] + + wc = WNDCLASSEX() + wc.cbSize = ctypes.sizeof(WNDCLASSEX) + wc.style = 0x0002 | 0x0001 + wc.lpfnWndProc = _wnd_proc + wc.hInstance = hinstance + wc.hbrBackground = ctypes.c_void_p(6) + wc.lpszClassName = name + user32.RegisterClassExW(ctypes.byref(wc)) + hwnd = user32.CreateWindowExW( + 0, name, 'Kiwy Nav Test', 0x00CF0000 | 0x10000000, + 40, 40, width, height, 0, 0, hinstance, 0, + ) + if not hwnd: + raise RuntimeError('CreateWindowExW failed') + user32.UpdateWindow(hwnd) + return hwnd + + +def _pump(seconds): + import ctypes.wintypes as wt + + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + msg = wt.MSG() + while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + time.sleep(0.02) + + +def _drive(browser, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + browser._tick(0) + _pump(0.05) + + +def _wait_nav(browser, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + browser._tick(0) + _pump(0.05) + if browser.navigation_succeeded() is not None: + return browser.navigation_succeeded() + return None + + +def main(): + print('=' * 68) + print(' WebView2 NavigationCompleted test') + print('=' * 68) + + from webview2_browser import WebView2Browser + + if not WebView2Browser.is_available(): + print('FAIL: WebView2 unavailable:', WebView2Browser._import_error) + return 1 + + httpd = _start_server() + good = f'http://127.0.0.1:{PORT}/index.html' + # A port nothing listens on: guarantees a real navigation failure. + bad = 'http://127.0.0.1:1/missing' + + hwnd = _make_window() + browser = WebView2Browser(hwnd_provider=lambda: hwnd) + + ok = True + + print(f'\n[1] good page: {good}') + browser.show(good) + _drive(browser, 1.0) + result = _wait_nav(browser, 20) + print(f' navigation_succeeded = {result}') + print(f' status = {browser.navigation_status()!r}') + if result is not True: + print(' FAIL: good page did not report success') + ok = False + + print(f'\n[2] bad page: {bad}') + browser.show(bad) + _drive(browser, 1.0) + result = _wait_nav(browser, 25) + print(f' navigation_succeeded = {result}') + print(f' status = {browser.navigation_status()!r}') + if result is not False: + print(' FAIL: bad page did not report failure') + ok = False + + browser.shutdown() + httpd.shutdown() + httpd.server_close() + + print('=' * 68) + print(' RESULT:', 'PASS' if ok else 'FAIL') + if ok: + print(' The player can tell a loaded page from a failed one,') + print(' so unreachable weblinks are skipped instead of shown blank.') + print('=' * 68) + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/windows/test_webview2_offline.py b/windows/test_webview2_offline.py new file mode 100644 index 0000000..5278885 --- /dev/null +++ b/windows/test_webview2_offline.py @@ -0,0 +1,221 @@ +"""Closed-network test: does a WebView2 page still load with no internet? + +The signage player lives on an isolated LAN, so the important question is not +"does example.com load" but "does a page on a reachable *local* host still +render when there is no internet at all". + +This test simulates that properly: + +1. Start a tiny HTTP server on 127.0.0.1 serving a known marker page. +2. Create the WebView2 environment **with the same offline browser arguments + the player uses** (webview2_browser._build_environment_options). +3. Navigate to the local page and confirm the page's actual content arrives — + not merely that the controller came up. + +It also blocks real internet resolution for the browser by pointing it at the +local server only, so a pass here means offline playback genuinely works. + +Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_offline.py +Exit code 0 = PASS. +""" + +import ctypes +import http.server +import os +import socketserver +import sys +import threading +import time +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +MARKER = 'KIWY-OFFLINE-LAN-OK' +PORT = 18765 + +PAGE = f""" +pc + +
{MARKER}
+""" + + +class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = PAGE.encode('utf-8') + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass # keep the test output clean + + +def _start_server(): + socketserver.TCPServer.allow_reuse_address = True + httpd = socketserver.TCPServer(('127.0.0.1', PORT), _Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + return httpd + + +# ── Win32 window (same approach as test_webview2_embed.py) ────────── +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 + +WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_int64, ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p +) + + +@WNDPROC +def _wnd_proc(hwnd, msg, wparam, lparam): + if msg == 0x0002: # WM_DESTROY + user32.PostQuitMessage(0) + return 0 + user32.DefWindowProcW.restype = ctypes.c_int64 + user32.DefWindowProcW.argtypes = [ + ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_void_p, + ] + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + +def _make_window(width=1280, height=720): + hinstance = kernel32.GetModuleHandleW(None) + class_name = 'KiwyWebView2OfflineTest' + + class WNDCLASSEX(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_uint), ('style', ctypes.c_uint), + ('lpfnWndProc', WNDPROC), ('cbClsExtra', ctypes.c_int), + ('cbWndExtra', ctypes.c_int), ('hInstance', ctypes.c_void_p), + ('hIcon', ctypes.c_void_p), ('hCursor', ctypes.c_void_p), + ('hbrBackground', ctypes.c_void_p), ('lpszMenuName', ctypes.c_wchar_p), + ('lpszClassName', ctypes.c_wchar_p), ('hIconSm', ctypes.c_void_p), + ] + + wc = WNDCLASSEX() + wc.cbSize = ctypes.sizeof(WNDCLASSEX) + wc.style = 0x0002 | 0x0001 + wc.lpfnWndProc = _wnd_proc + wc.hInstance = hinstance + wc.hbrBackground = ctypes.c_void_p(6) + wc.lpszClassName = class_name + user32.RegisterClassExW(ctypes.byref(wc)) + + hwnd = user32.CreateWindowExW( + 0, class_name, 'Kiwy Offline LAN Test', + 0x00CF0000 | 0x10000000, 60, 60, width, height, 0, 0, hinstance, 0, + ) + if not hwnd: + raise RuntimeError('CreateWindowExW failed') + user32.UpdateWindow(hwnd) + return hwnd + + +def _pump(seconds): + import ctypes.wintypes as wt + + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + msg = wt.MSG() + while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + time.sleep(0.02) + + +def main(): + print('=' * 68) + print(' WebView2 closed-network (LAN-only) test') + print('=' * 68) + + from webview2_browser import ( + WebView2Browser, _build_environment_options, _offline_browser_arguments, + ) + + if not WebView2Browser.is_available(): + print('FAIL: WebView2 unavailable:', WebView2Browser._import_error) + return 1 + + print('offline browser args:') + for flag in _offline_browser_arguments().split(): + print(f' {flag}') + + options = _build_environment_options() + if options is None: + print('\nFAIL: could not build offline environment options') + return 1 + print(f'\nAdditionalBrowserArguments set: ' + f'{bool(options.AdditionalBrowserArguments)}') + + httpd = _start_server() + url = f'http://127.0.0.1:{PORT}/dashboard' + print(f'\nlocal server: {url}') + + hwnd = _make_window() + browser = WebView2Browser(hwnd_provider=lambda: hwnd) + + started = time.monotonic() + if not browser.show(url): + print('FAIL: show() returned False:', browser.failed_reason) + httpd.shutdown() + return 1 + + visible = False + deadline = time.monotonic() + 25 + while time.monotonic() < deadline: + browser._tick(0) + _pump(0.1) + if browser.failed_reason: + print('FAIL:', browser.failed_reason) + httpd.shutdown() + return 1 + if browser.is_showing(): + visible = True + break + print(f'page visible : {visible} after {time.monotonic() - started:.1f}s') + + # Confirm the page's REAL CONTENT arrived, not just the controller. + # + # NOTE: ExecuteScriptAsync also returns a .NET Task. Calling .Result here + # would deadlock — the continuation needs this thread's message pump, which + # is exactly the mistake this file otherwise exists to catch. Poll it while + # pumping messages instead. + body = '' + if visible: + try: + task = browser._webview.ExecuteScriptAsync( + "document.getElementById('m').innerText" + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + _pump(0.05) + if task.IsCompleted: + body = task.Result + break + except Exception as exc: + print(f'note: script eval failed ({exc})') + got_marker = MARKER in (body or '') + print(f'page content : {"marker found" if got_marker else "MARKER MISSING"} ' + f'({(body or "")[:60]})') + + browser.shutdown() + httpd.shutdown() + httpd.server_close() + + ok = visible and got_marker + print('=' * 68) + print(' RESULT:', 'PASS' if ok else 'FAIL') + if ok: + print(' A local page renders with all internet traffic disabled —') + print(' web links work on a closed network.') + print('=' * 68) + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/windows/test_webview2_runtime.py b/windows/test_webview2_runtime.py new file mode 100644 index 0000000..2f5db1e --- /dev/null +++ b/windows/test_webview2_runtime.py @@ -0,0 +1,106 @@ +"""Standalone test for windows/webview2_runtime.py — no Kivy, no player. + +Checks the runtime-detection logic and (optionally) a real silent install. + +Run: windows\\venv\\Scripts\\python.exe windows\\test_webview2_runtime.py + windows\\venv\\Scripts\\python.exe windows\\test_webview2_runtime.py --install + +Without --install this is read-only: it reports the detected version and which +installer would be used. With --install it forces the installer path to run +(useful on a machine that genuinely lacks the Runtime). +Exit code 0 = checks passed. +""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import webview2_runtime as w # noqa: E402 + + +def main(): + force_install = '--install' in sys.argv + + print('=' * 68) + print(' WebView2 Runtime detection test') + print('=' * 68) + + version = w.get_runtime_version() + installed = w.is_runtime_installed() + print(f'registry/SDK version : {version or "(none)"}') + print(f'is_runtime_installed : {installed}') + + installer, kind = w.find_installer() + print(f'installer : {installer}') + print(f'installer kind : {kind}') + print(f'describe() : {w.describe()}') + + ok = True + + # Version parsing must be comparable and tolerant of junk. + cases = { + '152.0.4191.66': (152, 0, 4191, 66), + '1.2': (1, 2, 0, 0), + '': (0, 0, 0, 0), + None: (0, 0, 0, 0), + } + for raw, expected in cases.items(): + got = w._parse_version(raw) + flag = 'ok' if got == expected else 'FAIL' + if got != expected: + ok = False + print(f' parse({raw!r:16}) -> {got} [{flag}]') + + # An installer must be discoverable: without one, a Runtime-less machine + # has no way to recover. + if installer is None: + print('\nWARNING: no installer found — a machine without the Runtime ' + 'cannot self-heal.') + print('Run: .\\webview2_runtime\\download_runtime_installers.ps1') + else: + sig_status = 'n/a' + try: + import subprocess + + out = subprocess.run( + ['powershell', '-NoProfile', '-Command', + f'(Get-AuthenticodeSignature -LiteralPath "{installer}").Status'], + capture_output=True, text=True, timeout=60, + ) + sig_status = (out.stdout or '').strip() or 'unknown' + except Exception as exc: + sig_status = f'check failed: {exc}' + print(f'signature : {sig_status}') + + if force_install: + print('\n--install given: running the silent installer path...') + result = w.ensure_runtime(timeout=600) + print(f'ensure_runtime() -> {result}') + if not result.get('installed'): + ok = False + else: + # Read-only path: ensure_runtime must be a no-op that reports presence. + result = w.ensure_runtime(timeout=60) + print(f'\nensure_runtime() (read-only) -> {result}') + if installed and not result.get('installed'): + print('FAIL: Runtime present but ensure_runtime() disagreed') + ok = False + if result.get('action') not in ('already-present', 'installer-missing', + 'skipped-recent-failure', + 'installed-standalone', + 'installed-bootstrapper', + 'attempted-standalone', + 'attempted-bootstrapper'): + print(f'FAIL: unexpected action {result.get("action")!r}') + ok = False + + print('=' * 68) + print(' RESULT:', 'PASS' if ok else 'FAIL') + print('=' * 68) + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/windows/webview2_browser.py b/windows/webview2_browser.py new file mode 100644 index 0000000..735fe66 --- /dev/null +++ b/windows/webview2_browser.py @@ -0,0 +1,718 @@ +"""webview2_browser.py — Embedded WebView2 (Edge/Chromium) INSIDE Kivy's window. + +Why this exists +--------------- +The old weblink engines launched a *separate* browser process (Chrome/Edge +kiosk subprocess, or the dormant `cef_browser.py`). That model caused every +weblink bug in the tracker: the browser opening behind the Kivy window, being +handed off to an existing instance and exiting instantly, fighting for +foreground/z-order, and leaking `msedge.exe`/`chrome.exe` processes that were +never closed. + +WebView2 renders as a **child HWND of Kivy's own SDL window**, so: + + * no separate top-level window → nothing can open "in the background", + * nothing to hand the URL off to → no instant-exit hand-off, + * no z-order/foreground fight → it is literally a child of our window, + * teardown is ours → no leaked browser processes, + * the page renders at exactly the rectangle we give it (1920x1080 or + whatever the Kivy window currently is). + +Licensing / distribution: the WebView2 **runtime** is a free, evergreen, +Microsoft-shipped component (already present on this host as +``152.0.4191.66``). We only ship the small managed SDK + native loader DLLs. + +Implementation notes +-------------------- +* We talk to the .NET SDK through **pythonnet** (``clr``). +* Every WebView2 API is async (returns a .NET ``Task``). We must NOT call + ``.GetAwaiter().GetResult()``: the continuation needs the *same* thread's + message pump, so blocking would deadlock. Instead each task is **polled from + Kivy's Clock** (the SDL thread, which pumps Win32 messages) and consumed when + ``IsCompleted``. This mirrors how the old CEF code pumped via the Clock. +* All public methods are safe to call from the Kivy main thread. +""" + +from __future__ import annotations + +import ctypes +import os +import sys +import threading +from pathlib import Path + +# ── SDK discovery ──────────────────────────────────────────────────── +# The managed Microsoft.Web.WebView2.Core.dll and the native +# WebView2Loader.dll must sit in a folder we can find both in development and +# inside the PyInstaller bundle. +_SDK_ENV_VAR = 'KIWY_WEBVIEW2_SDK' + + +def _sdk_candidates(): + here = Path(__file__).resolve().parent + yield here / 'webview2_sdk' + # PyInstaller one-folder layout: bundled data lands next to the exe + # (sys._MEIPASS points at the temporary _MEIxxx dir). + meipass = getattr(sys, '_MEIPASS', None) + if meipass: + yield Path(meipass) / 'webview2_sdk' + yield here + + +def _find_sdk_dir(): + env = os.environ.get(_SDK_ENV_VAR) + if env and (Path(env) / 'Microsoft.Web.WebView2.Core.dll').is_file(): + return Path(env) + for candidate in _sdk_candidates(): + try: + if (candidate / 'Microsoft.Web.WebView2.Core.dll').is_file(): + return candidate + except OSError: + continue + return None + + +# ── Win32 helpers ──────────────────────────────────────────────────── +_SW_HIDE = 0 +_SW_SHOWNORMAL = 1 + + +class WebView2Browser: + """One embedded WebView2 instance parented to the Kivy (SDL) window. + + Lifecycle:: + + show(url) -> is_showing() (True once painted) -> resize(w,h) -> hide() + -> shutdown() + + ``hide()`` only hides the controller (it stays alive), so switching back to + a weblink later is instant. ``shutdown()`` disposes it for good. + """ + + #: Set True by the integration layer when the SDK + runtime are usable. + _import_error = None + + def __init__(self, hwnd_provider=None, user_data_dir=None): + self._hwnd_provider = hwnd_provider + self._user_data_dir = user_data_dir or os.path.join( + os.environ.get('KIWY_DATA_DIR', os.getcwd()), '.webview2-profile' + ) + self._env = None + self._controller = None + self._webview = None + self._hwnd = None + self._showing = False + self._stage = 'idle' # idle | env | controller | ready + self._pending_url = None + self._failed_reason = '' + self._poll_event = None + self._lock = threading.RLock() + self._task = None + self._task_kind = None + self._size = (0, 0) + # Navigation outcome. `_showing` only means "the controller was told to + # be visible", which happens the instant Navigate() is called — it says + # nothing about whether the page actually loaded. On a closed network + # that distinction is the whole point: an unreachable host paints a + # Chromium error page, so without this the player would show a blank + # error for the full slot instead of skipping the item. + self._navigation_ok = None # None = pending/unknown + self._navigation_status = '' + self._navigation_handlers = [] # keep refs: .NET must not GC these + + # ── Availability ───────────────────────────────────────────────── + @staticmethod + def is_available(): + """True when pythonnet + the SDK DLLs + a runtime are all present.""" + if sys.platform != 'win32': + return False + sdk = _find_sdk_dir() + if sdk is None: + return False + try: + import clr # noqa: F401 (pythonnet) + except Exception as exc: + WebView2Browser._import_error = f'pythonnet unavailable: {exc}' + return False + try: + cls = _load_webview2_types(sdk) + version = cls['env'].GetAvailableBrowserVersionString() + return bool(version) + except Exception as exc: + WebView2Browser._import_error = f'WebView2 unavailable: {exc}' + return False + + # ── Public API (Kivy main thread) ──────────────────────────────── + def show(self, url): + """Begin displaying ``url``. Returns True once the request is accepted. + + Rendering is asynchronous: the caller should poll :meth:`is_showing` + (the session's ``wait_visible`` does this on the watcher thread). + """ + with self._lock: + self._failed_reason = '' + self._pending_url = url + + if self._stage == 'ready' and self._controller is not None: + return self._navigate(url) + + if self._stage in ('env', 'controller'): + return True # already starting up; URL is queued + + # Start-up order: environment → controller → navigate. + self._stage = 'env' + if not self._start_environment(): + self._stage = 'idle' + return False + if self._stage != 'env': + # The environment resolved synchronously (fast path). + return self._after_environment() + return True + + def hide(self): + """Hide the page without destroying the controller (fast re-show).""" + with self._lock: + self._showing = False + self._pending_url = None + controller = self._controller + if controller is not None: + try: + controller.IsVisible = False + except Exception: + pass + + def is_showing(self): + """True while the page is actually on screen.""" + with self._lock: + if self._failed_reason: + return False + if self._controller is None: + return False + return self._showing + + def is_starting(self): + """True while the environment/controller is still being created. + + WebView2 start-up is asynchronous. A controller that does not exist yet + is NOT the same as a browser that has gone away, and conflating the two + made the *first* weblink after a cold start be skipped instantly (the + watcher saw "not alive" and advanced). Callers should treat + ``is_starting()`` as "still alive, not yet painted". + """ + with self._lock: + if self._failed_reason: + return False + return self._stage in ('env', 'controller') + + def is_alive(self): + """True when the browser is starting up or showing. False only on failure.""" + return self.is_showing() or self.is_starting() + + @property + def failed_reason(self): + return self._failed_reason + + def resize(self, width, height): + """Fit the page to ``width`` x ``height`` physical pixels.""" + width, height = int(width), int(height) + if width <= 0 or height <= 0: + return + with self._lock: + self._size = (width, height) + controller = self._controller + if controller is None: + return + try: + from System.Drawing import Rectangle + + controller.Bounds = Rectangle(0, 0, width, height) + except Exception as exc: + _log(f'WebView2 resize failed (non-fatal): {exc}') + + def shutdown(self): + """Dispose the controller and environment. Never raises.""" + with self._lock: + self._showing = False + self._stop_poll_locked() + controller, self._controller = self._controller, None + webview, self._webview = self._webview, None + env, self._env = self._env, None + self._stage = 'idle' + for obj, label in ((webview, 'webview'), (controller, 'controller')): + if obj is None: + continue + try: + dispose = getattr(obj, 'Dispose', None) + if dispose is not None: + dispose() + except Exception as exc: + _log(f'WebView2 {label} dispose failed (non-fatal): {exc}') + if ctypes is not None: + try: + ctypes.windll.ole32.CoUninitialize() + except Exception: + pass + del env + + # ── Start-up ───────────────────────────────────────────────────── + def _start_environment(self): + sdk = _find_sdk_dir() + if sdk is None: + self._failed_reason = 'WebView2 SDK not found' + _log('WebView2: SDK DLLs not found (expected Microsoft.Web.WebView2.Core.dll)') + return False + try: + types = _load_webview2_types(sdk) + except Exception as exc: + self._failed_reason = f'WebView2 SDK load failed: {exc}' + _log(f'WebView2: SDK load failed: {exc}') + return False + + # The controller must live on a thread with a message pump; Kivy's SDL + # thread qualifies, and COM must be initialised on it first. + try: + ctypes.windll.ole32.CoInitializeEx(None, 0x2) # STA + except Exception: + pass + + try: + os.makedirs(self._user_data_dir, exist_ok=True) + except Exception as exc: + _log(f'WebView2: could not create profile dir ({exc}); using temp') + import tempfile + + self._user_data_dir = tempfile.mkdtemp(prefix='kiwy-wv2-') + + _log(f'WebView2: creating environment (profile={self._user_data_dir})') + try: + options = _build_environment_options() + task = _create_environment_async(types, self._user_data_dir, options) + except Exception as exc: + self._failed_reason = f'CreateAsync failed: {exc}' + _log(f'WebView2: environment creation failed: {exc}') + return False + + self._task = task + self._task_kind = 'env' + self._start_poll() + return True + + def _start_controller(self): + hwnd = 0 + if self._hwnd_provider is not None: + try: + hwnd = self._hwnd_provider() or 0 + except Exception as exc: + _log(f'WebView2: hwnd provider failed: {exc}') + if not hwnd: + self._failed_reason = 'Kivy window handle not found' + _log('WebView2: could not locate the Kivy SDL window handle') + return False + self._hwnd = int(hwnd) + + _log(f'WebView2: creating controller inside hwnd=0x{self._hwnd:x}') + try: + # The parent window must be a .NET IntPtr; a plain Python int does + # not match the overload and pythonnet raises "No method matches + # given arguments". + from System import IntPtr + + parent = IntPtr(self._hwnd) + # HWND hosting: WebView2 creates its own child window in `parent`. + task = self._env.CreateCoreWebView2ControllerAsync(parent) + except Exception as exc: + self._failed_reason = f'controller creation failed: {exc}' + _log(f'WebView2: controller creation failed: {exc}') + return False + + self._task = task + self._task_kind = 'controller' + self._stage = 'controller' + self._start_poll() + return True + + def _after_environment(self): + """Called once the environment resolved.""" + if self._env is None: + return False + started = self._start_controller() + if not started and self._stage == 'controller': + return True # still coming up asynchronously + return started + + # ── Async task polling (Kivy Clock) ────────────────────────────── + def _start_poll(self): + try: + from kivy.clock import Clock + + if self._poll_event is None: + self._poll_event = Clock.schedule_interval(self._tick, 0.05) + except Exception: + # No Kivy (or called off-thread): poll from a plain timer instead. + if self._poll_event is None: + self._poll_event = _ThreadTimer(0.05, self._tick, None) + + def _stop_poll_locked(self): + event, self._poll_event = self._poll_event, None + if event is None: + return + try: + cancel = getattr(event, 'cancel', None) + if cancel is not None: + cancel() + else: + event.stop() + except Exception: + pass + + def _tick(self, _dt): + """Consume the in-flight Task once it completes.""" + with self._lock: + task, kind = self._task, self._task_kind + if task is None: + self._stop_poll_locked() + return False + try: + done = bool(task.IsCompleted) + except Exception as exc: + self._failed_reason = f'task poll failed: {exc}' + self._task = None + self._stop_poll_locked() + return False + if not done: + return True + self._task, self._task_kind = None, None + self._stop_poll_locked() + + try: + if task.IsFaulted: + exc = task.Exception + detail = '' + try: + detail = exc.GetBaseException().Message + except Exception: + detail = str(exc) + self._failed_reason = f'{kind} failed: {detail}' + _log(f'WebView2: {kind} task faulted: {detail}') + return False + result = task.Result + except Exception as exc: + self._failed_reason = f'{kind} task error: {exc}' + _log(f'WebView2: {kind} task error: {exc}') + return False + + if kind == 'env': + self._env = result + _log('WebView2: environment ready') + if not self._start_controller(): + self._stage = 'idle' + return False + + if kind == 'controller': + self._controller = result + self._on_controller_ready() + return False + + return False + + def _on_controller_ready(self): + """Wire up the page: bounds, settings, first navigation.""" + controller = self._controller + try: + controller.IsVisible = False # stay hidden until navigated + except Exception: + pass + + webview = None + try: + webview = controller.CoreWebView2 + except Exception as exc: + _log(f'WebView2: CoreWebView2 unavailable: {exc}') + if webview is None: + self._failed_reason = 'CoreWebView2 was not created' + return + self._webview = webview + + # Chrome-less, kiosk-like surface: no context menu, no devtools, + # no accelerators that could let an operator escape the signage. + try: + settings = webview.Settings + settings.AreDefaultContextMenusEnabled = False + settings.AreDevToolsEnabled = False + settings.IsStatusBarEnabled = False + settings.AreBrowserAcceleratorKeysEnabled = False + settings.IsZoomControlEnabled = False + settings.AreDefaultScriptDialogsEnabled = False + except Exception as exc: + _log(f'WebView2: settings tweak failed (non-fatal): {exc}') + + self._hook_navigation_events(webview) + + width, height = self._size + if width > 0 and height > 0: + self.resize(width, height) + + self._stage = 'ready' + _log('WebView2: controller ready') + + with self._lock: + url, self._pending_url = self._pending_url, None + if url: + self._navigate(url) + + def _hook_navigation_events(self, webview): + """Track whether the page actually loaded. + + ``is_showing()`` alone is misleading: it becomes True the moment + ``Navigate()`` is called, before anything has been fetched. On a closed + network the weblink host is often unreachable, and Chromium then paints + an error page — which the player must treat as a failure so the item is + skipped rather than shown as a broken screen for its whole slot. + + Handlers are stored on the instance: if the delegate were only a local, + the .NET GC would collect it and the event would silently stop firing. + """ + try: + handler = _NavigationCompletedHandler(self) + webview.NavigationCompleted += handler + self._navigation_handlers.append(handler) + _log('WebView2: navigation tracking enabled') + except Exception as exc: + # Not fatal: without it we simply cannot distinguish a loaded page + # from an error page, and fall back to "visible means OK". + _log(f'WebView2: could not hook NavigationCompleted ({exc})') + + def navigation_succeeded(self): + """True / False once navigation finished, None while still pending.""" + with self._lock: + return self._navigation_ok + + def navigation_status(self): + with self._lock: + return self._navigation_status + + def _navigate(self, url): + webview = self._webview + if webview is None: + return False + with self._lock: + self._navigation_ok = None + self._navigation_status = '' + try: + webview.Navigate(url) + except Exception as exc: + self._failed_reason = f'navigate failed: {exc}' + _log(f'WebView2: navigate failed: {exc}') + return False + width, height = self._size + if width > 0 and height > 0: + self.resize(width, height) + try: + self._controller.IsVisible = True + except Exception as exc: + _log(f'WebView2: could not show controller: {exc}') + return False + with self._lock: + self._showing = True + _log(f'WebView2: navigated to {url[:80]}') + return True + + +# ── Module helpers ─────────────────────────────────────────────────── +_TYPES_CACHE = {} + + +def _load_webview2_types(sdk_dir): + """Import the managed SDK and return the types we need (cached).""" + key = str(sdk_dir) + cached = _TYPES_CACHE.get(key) + if cached: + return cached + + if hasattr(os, 'add_dll_directory'): + try: + os.add_dll_directory(str(sdk_dir)) # let the loader find WebView2Loader.dll + except Exception: + pass + if key not in sys.path: + sys.path.insert(0, key) + + import clr + + # Framework assemblies we rely on (Rectangle for Bounds). + try: + clr.AddReference('System.Drawing') + except Exception: + pass + clr.AddReference(str(sdk_dir / 'Microsoft.Web.WebView2.Core.dll')) + + from Microsoft.Web.WebView2.Core import CoreWebView2Environment + + types = {'env': CoreWebView2Environment} + _TYPES_CACHE[key] = types + return types + + +def _create_environment_async(types, user_data_dir, options=None): + """Call CreateAsync with the options object. + + The SDK exposes exactly one overload: + ``CreateAsync(string browserExecutableFolder, string userDataFolder, + CoreWebView2EnvironmentOptions options)``. + """ + env_type = types['env'] + last = None + # Preferred: explicit options (used to pass offline browser arguments). + if options is not None: + try: + return env_type.CreateAsync(None, user_data_dir, options) + except Exception as exc: + last = exc + attempts = ( + (None, user_data_dir, None), + (None, user_data_dir), + ) + for args in attempts: + try: + return env_type.CreateAsync(*args) + except Exception as exc: + last = exc + raise last if last is not None else RuntimeError('CreateAsync failed') + + +def _offline_browser_arguments(): + """Chromium flags that stop internet chatter on a closed network. + + A signage player normally lives on an isolated LAN. By default Chromium + still tries to reach the internet for component updates, field trials, + safe-browsing lists, translate, and Google services. On a closed network + every one of those attempts has to time out, which costs start-up latency + (and, if DNS resolves but routes black-hole, can stall for many seconds). + + These flags disable that background traffic. They do NOT affect loading + actual pages — a weblink pointing at the local server still works, and one + pointing at the public internet simply fails fast with a normal + ERR_INTERNET_DISCONNECTED instead of hanging. + """ + return ' '.join([ + '--disable-background-networking', + '--disable-component-update', + '--disable-domain-reliability', + '--disable-features=Translate,OptimizationHints,MediaRouter,' + 'CalculateNativeWinOcclusion', + '--disable-sync', + '--no-first-run', + '--no-default-browser-check', + '--no-pings', + '--disable-breakpad', + '--metrics-recording-only', + '--disable-client-side-phishing-detection', + ]) + + +def _build_environment_options(): + """Create a CoreWebView2EnvironmentOptions with offline flags applied.""" + try: + from Microsoft.Web.WebView2.Core import CoreWebView2EnvironmentOptions + + options = CoreWebView2EnvironmentOptions() + options.AdditionalBrowserArguments = _offline_browser_arguments() + # Don't phone home with crash reports. + try: + options.IsCustomCrashReportingEnabled = False + except Exception: + pass + _log('WebView2: offline browser arguments applied') + return options + except Exception as exc: + _log(f'WebView2: could not build environment options ({exc}); ' + 'continuing with defaults') + return None + + +class _ThreadTimer: + """Minimal fallback timer used only when Kivy's Clock is unavailable.""" + + def __init__(self, interval, func, _unused): + self._interval = float(interval) + self._func = func + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self): + while not self._stop.wait(self._interval): + try: + if self._func(None) is False: + return + except Exception: + return + + def cancel(self): + self._stop.set() + + +class _NavigationCompletedHandler: + """Adapter for WebView2's ``NavigationCompleted`` event. + + The event is ``System.EventHandler`` + — there is no ``CoreWebView2NavigationCompletedEventHandler`` type to import + (attempting to import one fails). pythonnet converts a plain Python callable + to the generic delegate automatically, so that is what we pass. + + The callable is kept on the browser instance: a delegate referenced only by + a local would be collected by the .NET GC, after which the event silently + stops firing. + """ + + def __init__(self, browser): + self._browser = browser + + def __call__(self, sender, args): + """Fires on the WebView2 thread that owns the message loop.""" + try: + success = bool(args.IsSuccess) + status = _describe_navigation_error(args, success) if not success else '' + with self._browser._lock: + self._browser._navigation_ok = success + self._browser._navigation_status = status + if success: + _log('WebView2: page loaded') + else: + _log(f'WebView2: page failed to load ({status or "unknown"})') + except Exception as exc: + _log(f'WebView2: navigation handler error ({exc})') + + +def _log(message): + try: + from kivy.logger import Logger + + Logger.info(f'[WebView2] {message}') + except Exception: + print(f'[WebView2] {message}') + + +def _describe_navigation_error(args, success): + """Human-readable reason for a failed navigation. + + ``WebErrorStatus`` is an enum whose numeric value is not useful on its own; + when it reports ``Unknown`` (common for connection-level failures) the HTTP + status is more informative, so prefer whichever actually says something. + """ + parts = [] + try: + error_status = str(args.WebErrorStatus) + if error_status and error_status.lower() != 'unknown': + parts.append(error_status) + except Exception: + pass + try: + http_status = int(args.HttpStatusCode) + if http_status > 0: + parts.append(f'HTTP {http_status}') + except Exception: + pass + if parts: + return ', '.join(parts) + return 'connection failed (host unreachable or DNS failure)' diff --git a/windows/webview2_runtime.py b/windows/webview2_runtime.py new file mode 100644 index 0000000..4c2a25b --- /dev/null +++ b/windows/webview2_runtime.py @@ -0,0 +1,446 @@ +"""webview2_runtime.py — make sure the WebView2 Runtime is present. + +Why this exists +--------------- +WebView2 splits into two parts: + +* the **SDK** (the ``Microsoft.Web.WebView2.Core.dll`` + ``WebView2Loader.dll`` + we bundle in ``windows/webview2_sdk``), which is just the API surface, and +* the **Runtime** (``msedgewebview2.exe`` etc.), the actual Chromium engine. + +The SDK is useless without the Runtime. The Runtime ships with Windows 11 and +is present on the vast majority of Windows 10 machines, but Microsoft still +recommends checking for it and installing it when missing — so that is what +this module does. + +Deployment notes (per Microsoft's distribution guidance): + +* If the Runtime is missing we run an installer with ``/silent /install``. +* Run it **without elevation** → per-user install, which never shows a UAC + prompt. That matters for an unattended signage player: a UAC dialog on a + kiosk screen is a failure, not a prompt. +* Two installers are supported, in order of preference: + 1. ``MicrosoftEdgeWebView2RuntimeInstallerX64.exe`` — the ~203 MB offline + *standalone* installer. Works with no internet (drop it in + ``windows/webview2_runtime/`` to have it bundled). + 2. ``MicrosoftEdgeWebview2Setup.exe`` — the ~1.7 MB *bootstrapper*, which + downloads the Runtime from Microsoft. Bundled by default. + +Nothing here ever raises: a failure just means web links fall back to the +Chrome/Edge subprocess engine, which is far better than the player crashing. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +# Per Microsoft, the Runtime's presence/version lives in this registry value. +# (Edge Update client GUID for the Evergreen WebView2 Runtime.) +_CLIENT_GUID = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + +_STANDALONE_NAME = 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' +_BOOTSTRAPPER_NAME = 'MicrosoftEdgeWebview2Setup.exe' + +#: Don't re-attempt a failing install on every single start-up. +_RETRY_COOLDOWN_SECONDS = 6 * 60 * 60 + +_install_lock = threading.Lock() +_install_state = { + 'attempted': False, + 'installing': False, + 'installed': None, # bool once known + 'version': '', + 'error': '', +} + +#: Set once a background install has finished, so a weblink can wait for it. +_install_done = threading.Event() + + +# ── Detection ──────────────────────────────────────────────────────── +def _parse_version(text): + """Return a comparable tuple from a version string like '152.0.4191.66'.""" + parts = [] + for chunk in str(text or '').split('.'): + digits = ''.join(c for c in chunk if c.isdigit()) + parts.append(int(digits) if digits else 0) + while len(parts) < 4: + parts.append(0) + return tuple(parts[:4]) + + +def _read_registry_version(): + """Read the installed Runtime version from the registry, or ''. + + Checks both install scopes: HKLM (per-machine) and HKCU (per-user). On + 64-bit Windows the per-machine value lives under WOW6432Node because the + Edge Updater is a 32-bit component. + """ + if sys.platform != 'win32': + return '' + try: + import winreg + except Exception: + return '' + + candidates = [ + # (hive, subkey, access flag) + (winreg.HKEY_LOCAL_MACHINE, + rf'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', 0), + (winreg.HKEY_LOCAL_MACHINE, + rf'SOFTWARE\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', + getattr(winreg, 'KEY_WOW64_32KEY', 0)), + (winreg.HKEY_CURRENT_USER, + rf'SOFTWARE\Microsoft\EdgeUpdate\Clients\{_CLIENT_GUID}', 0), + ] + for hive, subkey, access in candidates: + try: + with winreg.OpenKey(hive, subkey, 0, + winreg.KEY_READ | access) as key: + value, _ = winreg.QueryValueEx(key, 'pv') + value = str(value or '').strip() + if value and _parse_version(value) > (0, 0, 0, 0): + return value + except Exception: + continue + return '' + + +def get_runtime_version(): + """Version of the installed Evergreen Runtime, or '' when absent.""" + version = _read_registry_version() + if version: + return version + # Fallback: ask the SDK itself (also covers preview channels). + try: + from webview2_browser import _find_sdk_dir, _load_webview2_types + + sdk = _find_sdk_dir() + if sdk is not None: + types = _load_webview2_types(sdk) + reported = types['env'].GetAvailableBrowserVersionString() + return str(reported).strip() if reported else '' + except Exception: + pass + return '' + + +def is_runtime_installed(): + """True when a usable WebView2 Runtime is present.""" + return bool(get_runtime_version()) + + +# ── Installer discovery ────────────────────────────────────────────── +def _search_dirs(): + """Folders that may hold an installer, best (standalone) first.""" + here = Path(__file__).resolve().parent + dirs = [here / 'webview2_runtime', here] + meipass = getattr(sys, '_MEIPASS', None) + if meipass: + dirs.append(Path(meipass) / 'webview2_runtime') + # Next to the .exe, so an operator can drop the offline installer in + # without rebuilding. + data_dir = os.environ.get('KIWY_DATA_DIR') + if data_dir: + dirs.append(Path(data_dir) / 'webview2_runtime') + dirs.append(Path(data_dir)) + env = os.environ.get('KIWY_WEBVIEW2_INSTALLER') + if env: + dirs.insert(0, Path(env).parent) + return dirs + + +def find_installer(): + """Locate a usable installer. Returns ``(path, kind)`` or ``(None, None)``. + + The standalone (offline) installer is preferred: it does not depend on the + target machine having internet access, which is the normal case for a + signage player on an isolated LAN. + """ + env = os.environ.get('KIWY_WEBVIEW2_INSTALLER') + if env and Path(env).is_file(): + return Path(env), 'explicit' + + found = {'standalone': None, 'bootstrapper': None} + for directory in _search_dirs(): + try: + if found['standalone'] is None: + candidate = directory / _STANDALONE_NAME + if candidate.is_file(): + found['standalone'] = candidate + if found['bootstrapper'] is None: + candidate = directory / _BOOTSTRAPPER_NAME + if candidate.is_file(): + found['bootstrapper'] = candidate + except OSError: + continue + + if found['standalone'] is not None: + return found['standalone'], 'standalone' + if found['bootstrapper'] is not None: + return found['bootstrapper'], 'bootstrapper' + return None, None + + +def _has_internet(timeout=4.0): + """Quick reachability probe. A closed network returns False fast.""" + try: + import socket + + with socket.create_connection(('www.msftconnecttest.com', 80), + timeout=timeout): + return True + except Exception: + return False + + +# ── Cooldown bookkeeping ───────────────────────────────────────────── +def _marker_path(): + data_dir = os.environ.get('KIWY_DATA_DIR') or os.getcwd() + return Path(data_dir) / 'logs' / '.webview2_install_attempt' + + +def _recent_failed_attempt(): + try: + marker = _marker_path() + if not marker.is_file(): + return False + age = time.time() - marker.stat().st_mtime + return age < _RETRY_COOLDOWN_SECONDS + except Exception: + return False + + +def _record_attempt(): + try: + marker = _marker_path() + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(str(int(time.time()))) + except Exception: + pass + + +def _clear_attempt_marker(): + try: + marker = _marker_path() + if marker.is_file(): + marker.unlink() + except Exception: + pass + + +# ── Install ────────────────────────────────────────────────────────── +def _create_no_window(): + """Keep the installer from flashing a console window on the signage.""" + try: + return subprocess.CREATE_NO_WINDOW + except AttributeError: + return 0x08000000 + + +def _run_installer(path, timeout): + """Run the installer silently. Returns (ok, detail).""" + # `/silent /install` is the documented silent invocation. Deliberately NOT + # elevated: a non-elevated run performs a per-user install, which never + # raises a UAC prompt on the kiosk display. + args = [str(path), '/silent', '/install'] + _log(f'WebView2: running installer {Path(path).name} /silent /install ' + f'(per-user, no elevation)') + try: + result = subprocess.run( + args, + timeout=timeout, + creationflags=_create_no_window(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except subprocess.TimeoutExpired: + return False, f'installer timed out after {int(timeout)}s' + except Exception as exc: + return False, f'could not run installer: {exc}' + + code = result.returncode + detail = (result.stdout or b'').decode('utf-8', 'replace').strip() + # Edge Update installers commonly report 0 (success) or 3010 (reboot + # required). They also return non-zero HRESULTs when the Runtime is already + # installed at an equal/newer version — which is why the caller decides + # success by re-reading the installed version rather than trusting this + # code. We only use it to explain a failure. + if code in (0, 3010): + return True, f'installer exit code {code}' + return False, f'installer exit code {code}{": " + detail if detail else ""}' + + +def ensure_runtime(timeout=600): + """Install the Runtime when missing. Blocking; never raises. + + Returns a dict describing the outcome (``installed``, ``version``, + ``error``, ``action``). + """ + with _install_lock: + if is_runtime_installed(): + version = get_runtime_version() + _install_state.update( + attempted=True, installing=False, installed=True, + version=version, error='', + ) + return dict(_install_state, action='already-present') + + if _recent_failed_attempt(): + _install_state.update( + attempted=True, installing=False, installed=False, error='', + ) + return dict(_install_state, action='skipped-recent-failure') + + path, kind = find_installer() + if path is None: + message = ('no WebView2 installer found (expected ' + f'{_STANDALONE_NAME} or {_BOOTSTRAPPER_NAME} in ' + 'windows/webview2_runtime/)') + _log(f'WebView2: {message}') + _install_state.update( + attempted=True, installing=False, installed=False, + error=message, + ) + return dict(_install_state, action='installer-missing') + + if kind == 'bootstrapper': + # The bootstrapper downloads the Runtime from Microsoft. On a closed + # network that can never succeed, so fail fast with an actionable + # message instead of hanging for the whole timeout. + if not _has_internet(): + message = ('the WebView2 Runtime is missing and this machine has ' + f'no internet access; only the ONLINE bootstrapper ' + f'({_BOOTSTRAPPER_NAME}) is available. Bundle the ' + f'offline installer ({_STANDALONE_NAME}, run ' + 'webview2_runtime/download_runtime_installers.ps1 ' + '-Offline) to run on a closed network.') + _log(f'WebView2: {message}') + _install_state.update( + attempted=True, installing=False, installed=False, + error=message, + ) + _install_done.set() + return dict(_install_state, action='offline-no-installer') + _log('WebView2: Runtime missing — using the ONLINE bootstrapper ' + '(downloads ~150 MB). Add the offline standalone installer to ' + 'avoid needing internet.') + + _install_state.update(attempted=True, installing=True, error='') + _record_attempt() + ok, detail = _run_installer(path, timeout) + if not ok and 'already installed' not in detail.lower(): + _log(f'WebView2: installer reported {detail}') + + # Decide success by RE-READING the installed version, not by the exit + # code: a non-zero HRESULT can simply mean "nothing to do". + version = '' + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + version = get_runtime_version() + if version: + break + time.sleep(1.0) + + if version: + _clear_attempt_marker() + _log(f'WebView2: Runtime available (v{version}) [{detail}]') + _install_state.update( + installing=False, installed=True, version=version, + error='', action=f'installed-{kind}', + ) + else: + message = detail or 'Runtime still not detected after install' + _log(f'WebView2: install did not take effect ({message})') + _install_state.update( + installing=False, installed=False, version='', error=message, + ) + _install_done.set() + return dict(_install_state, action=f'attempted-{kind}') + + +def ensure_runtime_async(timeout=600): + """Kick off :func:`ensure_runtime` on a background thread. + + Called at start-up so a missing Runtime installs while the player is still + syncing its playlist, instead of freezing the UI. + """ + if is_runtime_installed(): + _install_done.set() + _install_state.update(installed=True, version=get_runtime_version()) + return None + + def _worker(): + try: + ensure_runtime(timeout=timeout) + except Exception as exc: # defensive: never kill the player + _log(f'WebView2: background install failed: {exc}') + _install_state.update(installing=False, installed=False, error=str(exc)) + _install_done.set() + + thread = threading.Thread(target=_worker, name='webview2-install', daemon=True) + thread.start() + return thread + + +def wait_for_install(timeout): + """Wait (briefly, on the watcher thread) for a pending install. + + Returns True when a Runtime is available afterwards. + """ + if is_runtime_installed(): + return True + if not _install_state.get('installing'): + return False + _install_done.wait(timeout=max(0.0, float(timeout))) + return is_runtime_installed() + + +def get_state(): + """Snapshot of the installer state, for logging/diagnostics.""" + state = dict(_install_state) + if state.get('installed') is None: + state['installed'] = is_runtime_installed() + state['version'] = state['version'] or get_runtime_version() + return state + + +def describe(): + """One-line status for the startup log.""" + version = get_runtime_version() + if version: + return f'WebView2 Runtime present (v{version})' + path, kind = find_installer() + if path is None: + return 'WebView2 Runtime MISSING and no bundled installer found' + if kind == 'standalone': + return (f'WebView2 Runtime MISSING (will install OFFLINE via ' + f'{path.name} — no internet needed)') + return (f'WebView2 Runtime MISSING (will install via the ONLINE ' + f'bootstrapper {path.name}; needs internet)') + + +def is_offline_ready(): + """True when a Runtime is present, or can be installed without internet. + + This is the property that matters for a closed-network deployment: web + links will work on first start with no outbound connectivity. + """ + if is_runtime_installed(): + return True + path, kind = find_installer() + return path is not None and kind in ('standalone', 'explicit') + + +def _log(message): + try: + from kivy.logger import Logger + + Logger.info(f'[WebView2] {message}') + except Exception: + print(f'[WebView2] {message}') diff --git a/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe b/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe new file mode 100644 index 0000000..e10abbc Binary files /dev/null and b/windows/webview2_runtime/MicrosoftEdgeWebview2Setup.exe differ diff --git a/windows/webview2_runtime/download_runtime_installers.ps1 b/windows/webview2_runtime/download_runtime_installers.ps1 new file mode 100644 index 0000000..65426c4 --- /dev/null +++ b/windows/webview2_runtime/download_runtime_installers.ps1 @@ -0,0 +1,65 @@ +# Downloads the WebView2 Runtime installers into windows\webview2_runtime\. +# +# build.spec bundles: +# - MicrosoftEdgeWebview2Setup.exe (~1.7 MB) always +# - MicrosoftEdgeWebView2RuntimeInstallerX64.exe (~203 MB) only if present +# +# The bootstrapper is the small online installer (it downloads the Runtime +# from Microsoft). Run this script with -Offline to also fetch the standalone +# installer for machines that have no internet access — note that it makes the +# built .exe about 200 MB larger. +# +# Usage: +# .\download_runtime_installers.ps1 +# .\download_runtime_installers.ps1 -Offline + +[CmdletBinding()] +param( + [switch]$Offline +) + +$ErrorActionPreference = 'Stop' + +$dest = Join-Path $PSScriptRoot 'webview2_runtime' +if (-not (Test-Path $dest)) { + New-Item -ItemType Directory -Force -Path $dest | Out-Null +} + +$bootstrapperUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' +$standaloneUrl = 'https://go.microsoft.com/fwlink/?linkid=2124701' # x64 + +function Get-Installer { + param([string]$Url, [string]$FileName, [string]$Label) + + $target = Join-Path $dest $FileName + Write-Host "[INFO] Downloading $Label ..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $Url -OutFile $target -UseBasicParsing -MaximumRedirection 10 + + $file = Get-Item -LiteralPath $target + $sig = Get-AuthenticodeSignature -LiteralPath $target + $sizeMb = [math]::Round($file.Length / 1MB, 1) + + Write-Host (" {0} {1} MB" -f $file.Name, $sizeMb) + if ($sig.Status -eq 'Valid' -and $sig.SignerCertificate.Subject -like '*Microsoft*') { + Write-Host " signature: Valid (Microsoft)" -ForegroundColor Green + } + else { + Write-Warning " signature: $($sig.Status) — verify this download!" + } +} + +Get-Installer -Url $bootstrapperUrl -FileName 'MicrosoftEdgeWebview2Setup.exe' -Label 'Runtime bootstrapper (online)' + +if ($Offline) { + Get-Installer -Url $standaloneUrl -FileName 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe' -Label 'Runtime standalone installer (offline, x64)' + Write-Host '' + Write-Host '[WARN] The standalone installer adds ~203 MB to the built .exe.' -ForegroundColor Yellow +} +else { + Write-Host '' + Write-Host '[INFO] Offline installer skipped. Re-run with -Offline to include it.' -ForegroundColor DarkGray +} + +Write-Host '' +Write-Host "[OK] Installers are in $dest" -ForegroundColor Green +Write-Host ' Next: rebuild with venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm' diff --git a/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll b/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll new file mode 100644 index 0000000..23bfe7a Binary files /dev/null and b/windows/webview2_sdk/Microsoft.Web.WebView2.Core.dll differ diff --git a/windows/webview2_sdk/WebView2Loader.dll b/windows/webview2_sdk/WebView2Loader.dll new file mode 100644 index 0000000..965aed4 Binary files /dev/null and b/windows/webview2_sdk/WebView2Loader.dll differ