There are projects where you wish you could simply type Install-Package Microsoft.Web.WebView2 into the Package Manager Console and be done. And then there are projects like ours: an ERP application grown over years, built on a proprietary .NET Framework 4.6 WinForms framework, distributed to dozens of users simultaneously via Terminal Server Deployment (RDS). For the customer-specific forms, there is no modern NuGet workflow and no direct access to the project structure in Visual Studio. The task: replace static reports, printed for years, with interactive, Angular-based UIs—embedded directly in the WinForms client. The way forward was via WebView2. But how do you deploy the required components, if you can’t simply rebuild the setup project?
If you have ever integrated WebView2 in a “normal” .NET project, you know the standard way: add the NuGet package, the runtime is usually already present on the client, create a WebView2Environment, done. In a legacy environment without package manager access and with dozens of RDS sessions on the same server, practically every one of these steps is different.
Why the Standard Approach Doesn’t Work Here
Three basic conditions determine the entire solution:
- No NuGet / no build process. The customer-specific forms are not compiled, but rather provided as C# source code in a folder and loaded at runtime. The WebView2 assemblies therefore need to be included as static DLL references.
- RDS multi-user environment. Multiple users work simultaneously on the same terminal server. By default, WebView2 creates a
userDataFolder—without a proper separation, sessions would overwrite each other’s cookies, cache, and state. - No guarantee that the WebView2 runtime is pre-installed on every client. For a centrally distributed RDS image, you need a controlled, version-fixed deployment instead of the “evergreen” runtime, which updates itself and poses an incalculable risk for a production ERP system.
Step 1: Fixed Version Runtime instead of Evergreen
For RDS environments, the evergreen runtime is the wrong choice—it automatically updates itself in the background, which can lead to inconsistent behavior between sessions on a centrally managed terminal server. Instead, the Fixed Version Runtime is used: a specific WebView2 runtime version is downloaded once, stored locally on the server, and explicitly referenced in the code.
var environmentOptions = new CoreWebView2EnvironmentOptions();
var browserExecutableFolder = @"C:\ErpSystem\WebView2Runtime\";
var environment = await CoreWebView2Environment.CreateAsync(
browserExecutableFolder,
userDataFolder,
environmentOptions);
The browserExecutableFolder must point exactly to the folder containing the extracted fixed-version runtime—not to a parent directory and not to the .exe itself. An incorrect path here is one of the most common pitfalls and typically results in an unhelpful COMException when creating the environment instance.
Step 2: Static DLL Referencing without NuGet
Since we can’t use a package manager, the required WebView2 assemblies (Microsoft.Web.WebView2.Core.dll, Microsoft.Web.WebView2.WinForms.dll, Microsoft.Web.WebView2.Wpf.dll if needed, and the matching WebView2Loader.dll for the target architecture) need to be manually extracted from a NuGet package and added as classic project references. Important:
- The architecture (x86/x64) of
WebView2Loader.dllmust match the project’s target platform—a mismatch will only throw aBadImageFormatExceptionat runtime, not during compilation. - The DLLs must be marked as “Copy to Output Directory: Copy if newer” so that they are actually included in the deployment.
Step 3: Session Isolation via LocalApplicationData
Since multiple users work simultaneously on the same RDS host, each session requires its own isolated userDataFolder. The fix: dynamically create the folder per Windows user under LocalApplicationData.
string userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ErpSystemWebView2",
Environment.UserName);
Directory.CreateDirectory(userDataFolder);
A null or empty userDataFolder will reliably lead to an E_ACCESSDENIED as soon as WebView2 tries to write to a directory for which the process under the respective RDS user account has no write permission. The explicit, user-specific path assignment reliably fixes this.
Step 4: Mark-of-the-Web Blockade
A problem that especially occurs in deployment scenarios: Windows marks files that have been copied via network share or from the Internet with a so-called “Mark of the Web” (MOTW)—an alternate NTFS data stream that marks the file as potentially unsafe. Under this marking, WebView2 sometimes refuses to correctly load local content.
The most reliable solution is to explicitly remove the MOTW from the relevant files after deployment (e.g., using Unblock-File in a PowerShell deployment script) rather than circumventing the security marking in code.
Step 5: Bidirectional Communication between C# and Angular
The actual goal—the interactive UI—requires a two-way communication channel. From C# to JavaScript, this works via PostWebMessageAsJson:
webView.CoreWebView2.PostWebMessageAsJson(jsonPayload);
For larger data sets (in our case, around 200 orders, about 100 KB) exported from a DataTable, ExecuteScriptAsync after the NavigationCompleted event proved to be more reliable than PostWebMessageAsJson, since it ensures the Angular page is actually ready to receive the data.
The return path from JavaScript to C# works via COM-visible objects:
webView.CoreWebView2.AddHostObjectToScript("host", hostObject);
Important for .NET Framework 4.6: JavaScriptSerializer is used for serialization here instead of System.Text.Json, since the latter is only available from .NET Core/5+ or later .NET Framework versions.
On the Angular side, host object access is initiated with a pull-based approach from ngOnInit rather than waiting for push events—this makes the loading state more deterministic and easier to debug.
Summary
Ultimately, the result was a WebView2 setup that looks rather unspectacular at first glance—an embedded browser control exchanging data with Angular. The journey, however, was anything but: fixed version runtime instead of evergreen, static DLL references instead of NuGet, per-user userDataFolder instead of the default path, and manually removed Mark-of-the-Web. None of these steps are complicated per se—but each one will break deployment or throw a runtime error if you simply copy steps from a modern .NET tutorial into an RDS legacy environment without considering the constraints. The static printed report is now an interactive web UI with card design, which feels much more modern and responsive than simply using WinForms controls.