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,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())
|
||||
Reference in New Issue
Block a user