9f5409685d
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.
447 lines
16 KiB
Python
447 lines
16 KiB
Python
"""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}')
|