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.
170 lines
5.0 KiB
Python
170 lines
5.0 KiB
Python
"""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())
|