Embedded WebView2 engine for web links (Windows)
Web links previously launched a separate Chrome/Edge kiosk process, which
caused the whole class of bugs in the tracker: the browser opening behind the
player, being handed off to an already-running 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 instead, so there is
no separate top-level browser to open behind the player, nothing to hand the
URL off to, no z-order contest, and no leaked browser process.
Windows/webview2_browser.py
- Environment -> controller -> navigate, driven through pythonnet.
- Async .NET Tasks are polled from Kivy's Clock. Calling GetAwaiter()
.GetResult() would deadlock: the continuation needs the same thread's
message pump.
- The controller is a .NET IntPtr, not a Python int (CreateAsync overloads
do not match otherwise).
- NavigationCompleted is tracked so a page that never loads can be told
apart from one that did. This matters on a closed network: an unreachable
host paints a Chromium error page, and without this the player would show
a blank/error screen for the item's whole slot instead of skipping it.
- is_alive() reports True while starting up. Start-up is async, so a
controller that does not exist yet is not a dead browser; treating it as
one made the first web link after a cold start be skipped instantly.
Windows/webview2_runtime.py
- Detects the Runtime (registry pv value, SDK probe as fallback) and
installs it silently when missing, unelevated, which produces a per-user
install and therefore never raises a UAC prompt on the signage display.
- Success is decided by RE-READING the installed version, not by the
installer exit code: Edge Update returns a non-zero HRESULT
(-2147219416) when the Runtime is already current, which is not a failure.
- On a closed network the online bootstrapper can never succeed, so it fails
fast with an actionable message instead of hanging for the full timeout.
- Failed attempts are cooldown-gated so a broken machine does not re-run an
installer on every start.
Offline hardening
- Browser arguments disable component updates, field trials, safe-browsing
list fetches, translate and other internet chatter. On an isolated LAN
each of those would otherwise have to time out, costing start-up latency.
Pages on the local server are unaffected.
Engine order (best first): WebView2 -> CEF -> Chrome/Edge subprocess. CEF has
no wheels past Python 3.9 so it is dormant on this build; the subprocess engine
remains only as a last resort.
Also fixes the reason the Windows adapters were never used at all:
SignagePlayer.__init__ assigned self.weblink_adapter_factory = None, which
shadowed the CLASS attribute that run_win.py injects. play_weblink() therefore
fell back to the generic adapter, whose find_browser() uses shutil.which() and
finds nothing on Windows because Chrome/Edge are not on PATH. The instance
attribute is now only set when the class attribute is absent.
Verified: windows/test_webview2_embed.py, test_webview2_navigation.py and
test_webview2_offline.py all pass (a locally served page renders with all
internet traffic disabled), and the packaged exe reports
"weblink_launch engine=webview2-embedded" -> "weblink_launched" on every cycle
with no leaked browser processes.
This commit is contained in:
+67
-5
@@ -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={},
|
||||
|
||||
Reference in New Issue
Block a user