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
+67 -5
View File
@@ -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={},
+12
View File
@@ -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):
+169
View File
@@ -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())
+186
View File
@@ -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 = '<!doctype html><html><body><h1 id="h">KIWY-NAV-OK</h1></body></html>'
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())
+221
View File
@@ -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"""<!doctype html>
<html><head><meta charset="utf-8"><title>pc</title></head>
<body style="background:#123;color:#fff;font:48px sans-serif">
<div id="m">{MARKER}</div>
</body></html>"""
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())
+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())
+718
View File
@@ -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<CoreWebView2NavigationCompletedEventArgs>``
— 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)'
+446
View File
@@ -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}')
@@ -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'
Binary file not shown.
Binary file not shown.