# -*- 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:/` 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))