Files
willtools-demo/WillTools.extension/lib/vibe/webhost.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

321 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""WebHost -- a WebView2 (Chromium) window a pyRevit button can pop up.
This is the new home for a tool's front end. Panel Analysis's WPF form is
being replaced by an HTML page; beam and column tools after it reuse this
same host rather than writing their own WPF.
OWNER SCOPE UPDATE (2026-09-05): the HTML page is served by a CPython local
HTTP server outside Revit -- it also has to run with no Revit open at all --
and it talks to that server with `fetch`, not to this module. So `WebHost` is
just the window: point it at a `file://` path OR an `http://127.0.0.1:<port>/`
URL with `navigate()`, and it shows the page. The `on_message` /
`window.chrome.webview.postMessage` bridge is kept because it is cheap and
useful for things like "close this window" or logging from the page, but it
is not the main channel any more and is not built out further than what is
here (no `SetVirtualHostNameToFolderMapping` -- a plain `file://` URL is
enough for the demo page, and the real pages come from `http://` anyway).
WHERE THE ASSEMBLIES COME FROM
--------------------------------
Nothing here ships a WebView2 SDK. Revit's own install folder already carries
the two managed assemblies this needs --
`Microsoft.Web.WebView2.{Core,Wpf}.dll` -- because Revit's own UI uses
WebView2 internally. Verified on this machine across the 2024/2025/2026
folders. What differs is where `WebView2Loader.dll` (the native loader) sits:
2025 and 2026 keep it at the top level beside the managed DLLs; 2024 keeps it
only under `runtimes\win-x64\native\`. `_ensure_webview2_assemblies()` below
handles both, and raises `WebHostError` naming exactly what is missing rather
than letting a `System.IO.FileNotFoundException` come out of `clr` with no
context.
WHY THE USER DATA FOLDER IS EXPLICIT
--------------------------------------
`CoreWebView2Environment.CreateAsync` wants a folder it can write a profile
into, and Revit's own install folder (`Program Files\...`) is read-only to an
ordinary user. Default it to `%LOCALAPPDATA%\WillTools\webview2`, created if
missing, and never fall back to wherever the process happens to be running
from.
WHY INIT LOOKS LIKE THIS
---------------------------
IronPython 2.7 cannot `await`. `CoreWebView2Environment.CreateAsync` returns a
.NET `Task`, so the only way to react to it is `Task.ContinueWith`, whose
callback runs on a THREAD-POOL thread, not the UI thread -- touching a WPF
object there raises `InvalidOperationException: The calling thread cannot
access this object`. So the continuation's only job is to marshal back onto
the window's own `Dispatcher` before calling `EnsureCoreWebView2Async`, and
`CoreWebView2InitializationCompleted` (an ordinary WPF event, fired back on
the UI thread) is where `post()` / `navigate()` calls made before the page is
ready get flushed -- see `_pending` below.
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
import json
import os
import clr
clr.AddReference('PresentationFramework')
clr.AddReference('PresentationCore')
clr.AddReference('WindowsBase')
import System
from System import Action, EventHandler, Uri
from System.Windows import Window, WindowStartupLocation
from vibe.compat import string_types
class WebHostError(Exception):
"""WebView2 could not be loaded or started. The message names what is missing."""
_STATE = {'assemblies_loaded': False}
def _revit_install_dir():
"""The folder Revit.exe itself runs from, from the live process."""
proc = System.Diagnostics.Process.GetCurrentProcess()
return os.path.dirname(proc.MainModule.FileName)
def _point_loader_at(native_dir):
"""Make `WebView2Loader.dll` findable when it is not beside the managed DLLs.
Prefer `CoreWebView2Environment.SetLoaderDllFolderPath` (added in a later
Core release than some Revit installs carry) and fall back to PATH, which
every version of the native loader will search.
"""
from Microsoft.Web.WebView2.Core import CoreWebView2Environment
set_loader = getattr(CoreWebView2Environment, 'SetLoaderDllFolderPath', None)
if set_loader is not None:
try:
set_loader(native_dir)
return
except Exception:
pass
path_var = System.Environment.GetEnvironmentVariable('PATH') or ''
existing = path_var.split(os.pathsep)
if native_dir not in existing:
System.Environment.SetEnvironmentVariable(
'PATH', native_dir + os.pathsep + path_var)
def _ensure_webview2_assemblies():
"""Load the two managed WebView2 DLLs out of Revit's own install folder.
Raises `WebHostError` naming exactly what file is missing and where it
looked, rather than a bare `clr` exception with no context. Safe to call
more than once -- only does the work the first time.
"""
if _STATE['assemblies_loaded']:
return
install_dir = _revit_install_dir()
core_dll = os.path.join(install_dir, 'Microsoft.Web.WebView2.Core.dll')
wpf_dll = os.path.join(install_dir, 'Microsoft.Web.WebView2.Wpf.dll')
for dll_path in (core_dll, wpf_dll):
if not os.path.isfile(dll_path):
raise WebHostError(
'WebView2 assembly not found: {0} -- this Revit install '
'({1}) does not carry the WebView2 runtime.'.format(
dll_path, install_dir))
clr.AddReferenceToFileAndPath(core_dll)
clr.AddReferenceToFileAndPath(wpf_dll)
top_loader = os.path.join(install_dir, 'WebView2Loader.dll')
if not os.path.isfile(top_loader):
native_dir = os.path.join(install_dir, 'runtimes', 'win-x64', 'native')
native_loader = os.path.join(native_dir, 'WebView2Loader.dll')
if not os.path.isfile(native_loader):
raise WebHostError(
'WebView2Loader.dll not found at {0} or {1} -- cannot start '
'WebView2.'.format(top_loader, native_loader))
_point_loader_at(native_dir)
_STATE['assemblies_loaded'] = True
def _default_user_data_dir():
base = os.environ.get('LOCALAPPDATA') or os.path.expanduser('~')
path = os.path.join(base, 'WillTools', 'webview2')
if not os.path.isdir(path):
try:
os.makedirs(path)
except OSError:
if not os.path.isdir(path):
raise
return path
class WebHost(object):
"""One WebView2 window: construct it, then `.show()` or `.show_dialog()`.
`on_message(host, obj)` fires for every `window.chrome.webview.postMessage`
call the page makes, with `obj` the parsed JSON. `post(obj)` is the other
direction. Both may be called before the page has finished initialising --
they queue and flush once it has (see `_pending`).
"""
def __init__(self, title, html_path, on_message, width=1250, height=900,
user_data_dir=None):
_ensure_webview2_assemblies()
from Microsoft.Web.WebView2.Wpf import WebView2
self.title = title
self.init_error = None
self._on_message = on_message
self._initial_source = html_path
self._user_data_dir = user_data_dir or _default_user_data_dir()
self._ready = False
self._pending = [] # [('post', json_str) | ('navigate', path_or_url), ...]
self._window = Window()
self._window.Title = title
self._window.Width = width
self._window.Height = height
self._window.WindowStartupLocation = WindowStartupLocation.CenterScreen
self._webview = WebView2()
self._window.Content = self._webview
self._window.Closed += self._on_window_closed
self._start_init()
# ------------------------------------------------------------- init
def _start_init(self):
from Microsoft.Web.WebView2.Core import CoreWebView2Environment
try:
env_task = CoreWebView2Environment.CreateAsync(
None, self._user_data_dir, None)
except Exception as ex:
self._fail_init(
'CoreWebView2Environment.CreateAsync raised: {0}'.format(ex))
return
from System.Threading.Tasks import Task
# `ContinueWith` is overloaded on both the closed generic
# `Task[CoreWebView2Environment]` and the base `Task` -- with a bare
# `Action[Task]` .NET cannot tell which one we mean and raises
# "Multiple targets could match". Name the closed generic type so the
# overload is unambiguous.
env_task.ContinueWith(
Action[Task[CoreWebView2Environment]](self._on_env_created))
def _on_env_created(self, task):
# Runs on a thread-pool thread -- touch nothing WPF here directly.
def _continue():
if task.IsFaulted:
self._fail_init(
'CoreWebView2Environment.CreateAsync failed: {0}'.format(
task.Exception))
return
env = task.Result
try:
from Microsoft.Web.WebView2.Core import (
CoreWebView2InitializationCompletedEventArgs)
self._webview.CoreWebView2InitializationCompleted += (
EventHandler[CoreWebView2InitializationCompletedEventArgs](
self._on_core_init))
self._webview.EnsureCoreWebView2Async(env)
except Exception as ex:
self._fail_init('EnsureCoreWebView2Async raised: {0}'.format(ex))
self._window.Dispatcher.Invoke(Action(_continue))
def _on_core_init(self, sender, e):
if not e.IsSuccess:
self._fail_init('CoreWebView2 initialization failed: {0}'.format(
e.InitializationException))
return
core = self._webview.CoreWebView2
core.Settings.AreDevToolsEnabled = True
core.Settings.IsStatusBarEnabled = False
from Microsoft.Web.WebView2.Core import (
CoreWebView2WebMessageReceivedEventArgs)
core.WebMessageReceived += (
EventHandler[CoreWebView2WebMessageReceivedEventArgs](
self._on_web_message))
self._ready = True
self._do_navigate(self._initial_source)
pending, self._pending = self._pending, []
for kind, payload in pending:
if kind == 'post':
self._do_post(payload)
else:
self._do_navigate(payload)
def _fail_init(self, message):
self.init_error = message
try:
print('WebHost init failed: {0}'.format(message))
except Exception:
pass
def _on_web_message(self, sender, e):
try:
obj = json.loads(e.WebMessageAsJson)
except Exception as ex:
obj = {'_parse_error': '{0}'.format(ex)}
if self._on_message is not None:
self._on_message(self, obj)
def _on_window_closed(self, sender, args):
self._dispose_webview()
# ----------------------------------------------------------- public
def post(self, obj):
"""Send `obj` (a dict/list, JSON-encoded) to the page."""
payload = json.dumps(obj)
if self._ready:
self._do_post(payload)
else:
self._pending.append(('post', payload))
def _do_post(self, payload_json):
self._webview.CoreWebView2.PostWebMessageAsJson(payload_json)
def navigate(self, path_or_url):
"""Point the page at a local file path or an `http(s)://` URL."""
if self._ready:
self._do_navigate(path_or_url)
else:
self._pending.append(('navigate', path_or_url))
def _do_navigate(self, path_or_url):
self._webview.Source = _to_uri(path_or_url)
def show_dialog(self):
"""Modal: blocks until the window is closed."""
self._window.ShowDialog()
def show(self):
"""Non-modal: returns immediately, window stays open."""
self._window.Show()
def close(self):
self._dispose_webview()
try:
self._window.Close()
except Exception:
pass
def _dispose_webview(self):
if self._webview is None:
return
try:
self._webview.Dispose()
except Exception:
pass
self._webview = None
def _to_uri(path_or_url):
"""A local file path or an already-complete URL, either way -> `System.Uri`."""
if isinstance(path_or_url, string_types) and (
path_or_url.startswith('http://')
or path_or_url.startswith('https://')
or path_or_url.startswith('file://')):
return Uri(path_or_url)
return Uri(os.path.abspath(path_or_url))