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:
@@ -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}')
|
||||
Reference in New Issue
Block a user