8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
321 lines
12 KiB
Python
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))
|