WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
# -*- 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))
|
||||
Reference in New Issue
Block a user