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:
ske087
2026-09-13 10:14:18 +03:00
parent eb8e66e427
commit 9f5409685d
15 changed files with 2418 additions and 66 deletions
+106
View File
@@ -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())