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:
+67
-5
@@ -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={},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)'
|
||||
@@ -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}')
|
||||
Binary file not shown.
@@ -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.
Reference in New Issue
Block a user