You have an old .NET app and would like to upgrade to .NET 6, to be up-to date with the latest features and go forward with the app.

Every time you think about it, you notice that you have no time to do it and there are more important things to do. And you see that you’re stuck with an old .NET version. But, in this case, Microsoft has helped you to be able to accomplish your New Year’s resolutions. Now, you have the .NET upgrade assistant to help you to migrate to the latest .NET version: the >NET upgrade assistant.

With it, you can migrate your old .NET app to .NET 6 with very simple steps. It may not be a full migration, but it’s a starting point, which eases a lot the migration.

The first step is have dotnet installed in your machine. If you haven’t done so, you can install it from https://dotnet.microsoft.com/en-us/download/dotnet/6.0. Once installed, you can check the installed version by opening a terminal window and  typing the dotnet --versioncommand.

Then, you should install the upgrade assistant with

dotnet tool install -g upgrade-assistant

Once installed, you can use it with

upgrade-assistant

To demonstrate the procedure, I will use the project DiskAnalisys, from https://github.com/bsonnino/DiskAnalysis. This is a .NET 4.5 project and has many features that need to be upgraded: the FolderPicker, Charts, etc. The first step is to clone the project and run the solution in Visual Studio.

When you click the start button, you will be asked for a folder to analyze and, then, a list of files will be shown, ordered by size. On the other tabs, you can see a chart for the extensions and another for the contribution of each file for the total size:

You can check the .NET version in the properties for the project:

Now we can upgrade the project. Open a terminal window, change to the solution’s directory and type

upgrade-assistant analyze diskanalysis.sln

This will show a report for the upgrade of the project:

If you want the detailed output, you can use the -v option to the command line. In the project’s folder, you can see two files, AnalysisReport.sarif, a json file with the report and upgrade-assistant.clef, a copy of what was output to the screen.

The report shows that the project can be updated, so we will update it with

upgrade-assistant upgrade diskanalysis.sln

That will show the steps that will be followed in the upgrade:

You will move from one step to the other by pressing 1. The steps are:

1. Back up project

Backup the project, so it can be used, if the upgrade fails. The program asks for the path for the backup then copies the project

2. Convert project file to SDK style

Converts the csproj file from the old style to the new style. The new file is changed to something like this:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net452</TargetFramework>
    <OutputType>WinExe</OutputType>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
    <UseWPF>true</UseWPF>
    <ImportWindowsDesktopTargets>true</ImportWindowsDesktopTargets>
  </PropertyGroup>
  <ItemGroup>
    <AppDesigner Include="Properties\" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="DotNetProjects.Wpf.Toolkit" Version="5.0.31" />
    <PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
    <PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
    <PackageReference Include="WPFFolderBrowser" Version="1.0.2" />
  </ItemGroup>
</Project>

3. Clean up NuGet package references

This task will clean up the NuGet package references and add the reference to Microsoft.DotNet.UpgradeAssistant.Extensions.Default.Analyzers

4. Update TFM

This task will change the target framework in the csproj to net6.0-windows

5. Update NuGet Packages

This step will check if there are updates in the NuGet packages, update them and add a reference to Microsoft.Windows.Compatibility in the csproj

When you press 1 to go to the next step, the following steps are skipped

6. Add template files
7. Upgrade app config files
a. Convert Application Settings
b. Convert Connection Strings
c. Disable unsupported configuration sections
8. Update source code

a. Apply fix for UA0002: Types should be upgraded
b. Apply fix for UA0012: ‘UnsafeDeserialize()’ does not exist

And the conversion is finished. You must only press 1 to go to the last step:

9. Move to next project

There were no changes in the source code, just in the csproj. If you have your project still opened in Visual Studio, it will ask you to reload it. If you reload it, you will see that it changed to .NET 6 (if you are using VS 2019 you won’t see that, because VS 2019 doesn’t support .NET 6). If you go to the Solution Explorer, you will see that there are two warnings in the dependency packages:

The WPF Toolkit and the WPF Folder Browser packages don’t have the versions for .NET 6, but we’ll run the project to see if it runs fine. Once you run it, you can see that it woks fine, the same way as it did originally. But this time, you get a .NET 6 app running.

We can fix the issues and have a full .NET 6 app running with some extra effort. The WPF Toolkit has been updated and you can use the new packages from https://github.com/dotnetprojects/WpfToolkit. You just have to remove the old package and add the package DotNetProjects.WPF.Toolkit.DataVisualization. For the WPF Folder Browser, you don’t have an updated package, but you can use the source code from https://github.com/McNeight/WpfFolderBrowser.

Clone the repository, run the Upgrade Assistant in the project, to convert it to .NET 6 the same way you did with the main project and add it to the solution. Remove the WPFFolderBrowser package form the project and add a reference for the modified project. When you rebuild the solution, you will get an error Error CA0052 : No targets were selected.

You must edit the project file and remove this property group:

<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
   <RunCodeAnalysis>true</RunCodeAnalysis>
   <CodeAnalysisRules>
   </CodeAnalysisRules>
   <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
   <CodeAnalysisRuleSet>AllRules.ruleset</CodeAnalysisRuleSet>
 </PropertyGroup>

Once you do that, the project compiles fine, but when you try to run it, you get an error saying that WPFFolderBrowserDialog was not found. The only change here is to change the namespace in MainWindow.xaml.cs to WpfFolderBrowser.

Now you can run the project, it runs fine and there are no more compatibility issues.

As you can see, the upgrade assistant eases the migration to .NET 6 a lot. Sometimes, you will need an extra effort to have your app running, but it’s surely easier than doing it by hand. This is a nice step to evolve your apps and port them to the latest version of .NET.

The full code for this article is at https://github.com/bsonnino/UpgradeAssistant

In the last post I showed how to transform your Asp.Net Core MVC app into a PWA, thus allowing to install it and access the OS features. We can go a step further and transform it into a native app, using Electron. You may ask “Electron, what’s it ? I’ve never heard of it”. You may never heard of it, but I’m sure that you’ve already used it: VS Code, Teams and Slack are some examples of Electron apps: they are apps developed with web technologies and packaged with the Electron shell.

The two types of applications, although similar, are completely different. A PWA is an app provided by the browser and has the features that are available on the used browser – for example, if you install it from Chrome, it will have the features offered by Chrome (which may not be the same offered by Edge). An Electron app is a native app and is independent of the browser (as a matter of fact, an Electron app creates its own chromium window). It offers full OS interaction and is a real desktop application.

It also have its downsides: the app size is larger, as it will have all the support needed to create a desktop application and you will have to install it like a desktop application. Which one you should choose? It depends on what do you want – if you want a lightweight app with easy install with mostly web features, the a PWA is a way to go. If, on the other side, you want a desktop app that accesses the full OS and does not depend on the installed browser, then Electron is for you.

We will create a new Dotnet 6 Web App and convert it to an Electron app. For that, we will use Electron.NET, a wrapper around the full Electron, which provides a toolset for transforming your ASP.NET apps into an Electron app.

The first step is to create the app in the command line. Open Windows Terminal and type:

dotnet new mvc -o MvcElectron

This will create the app in the MvcElectron folder. Change to that folder and then install the Electron.NET NuGet package, with this command:

dotnet add package electronnet.api

Once you’ve installed it, we must tell to use Electron in the app. Open VS Code (using the command Code .), and, in Program.cs, add:

using ElectronNET.API;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.UseElectron(args);

Then, in the end of the file we’ll create the main Electron window:

if (HybridSupport.IsElectronActive)
{
    CreateElectronWindow();
}

app.Run();

async void CreateElectronWindow()
{
    var window = await Electron.WindowManager.CreateWindowAsync();
    window.OnClosed += () => Electron.App.Quit();
}

Then, we must install the Electron CLI with

dotnet tool install ElectronNET.CLI -g

Once we do that, we can use the tool using the electronizecommand. We will initialize the project with

electronize init

You should open a command line in the project’s folder. This command will add a manifest and will add it to your project file. Once you do that, you will be able to run it with

electronize start

This is a native app, with an icon and a full menu. You can notice that, in the View menu, you can open the developer tools for the web app. This app is not dependent on any browser, thus you can have any installed browser and it will run the same. The application is built for the OS where you ran the commands: if you are developing on Windows, it’s a Windows app. If you are developing on a Mac or on Linux, it will be a Mac or a Linux app.

Now, let’s customize it. The first customization is to change the window size. The window size is an option that you will pass when creating the main window. In Program.cs, add this code:

async void CreateElectronWindow()
{
    var options = new BrowserWindowOptions
    {
        Width = 1024,
        Height = 1024
    };
    var window = await Electron.WindowManager.CreateWindowAsync(options);
    window.OnClosed += () => Electron.App.Quit();
}

To use this code, you must add ElectronNET.API.Entities to the usings. After you save and restart the app, you will see that the window has the new size.

The next customization is the app menu. Unfortunately, there is no way to add a single menu option to the main menu, you should replace the entire menu. We create the menu before creating the window:

if (HybridSupport.IsElectronActive)
{
    CreateMenu();
    CreateElectronWindow();
}

The CreateMenu function is:

void CreateMenu()
{
    var fileMenu = new MenuItem[]
    {
        new MenuItem { Label = "Home", 
                                Click = () => Electron.WindowManager.BrowserWindows.First().LoadURL($"http://localhost:{BridgeSettings.WebPort}/") },
        new MenuItem { Label = "Privacy", 
                                Click = () => Electron.WindowManager.BrowserWindows.First().LoadURL($"http://localhost:{BridgeSettings.WebPort}/Privacy") },
        new MenuItem { Type = MenuType.separator },
        new MenuItem { Role = MenuRole.quit }
    };

    var viewMenu = new MenuItem[]
    {
        new MenuItem { Role = MenuRole.reload },
        new MenuItem { Role = MenuRole.forcereload },
        new MenuItem { Role = MenuRole.toggledevtools },
        new MenuItem { Type = MenuType.separator },
        new MenuItem { Role = MenuRole.resetzoom },
        new MenuItem { Role = MenuRole.zoomin },
        new MenuItem { Role = MenuRole.zoomout },
        new MenuItem { Type = MenuType.separator },
        new MenuItem { Role = MenuRole.togglefullscreen }
    };

    var menu = new MenuItem[] 
    {
        new MenuItem { Label = "File", Type = MenuType.submenu, Submenu = fileMenu },
        new MenuItem { Label = "View", Type = MenuType.submenu, Submenu = viewMenu }
    };

    Electron.Menu.SetApplicationMenu(menu);
}

As you can see, we added the two pages in the File menu and recreated the View menu. That way, we don’t need the menu and the footer in the main web page. In _Layout.cshtml, you can remove the header and the footer. That way, we are only using the main menu for changing pages.

We can go further and access the computer’s files. But before that, we’ll setup the watch feature, that will watch for any file changes and will reload the app automatically. For that you must start the app with

electronize start /watch

That way, every change will be detected and the app will be updated with no need to restart. In order to run the Dotnet 6 app in watch mode, a small change must be made in the file Properties\launchsettings.json: change the launch profile to use the port used by Electron:

"MvcElectron": {
    "commandName": "Project",
    "dotnetRunMessages": true,
    "launchBrowser": true,
    "applicationUrl": "http://localhost:8001",
    "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
    }
},

Once you do that, you can launch the app in watch mode. Now we can start making our changes in the main page.

We’ll get the 15 larger files in My Documents folder and display them in the page. For that, in the folder Models, create a new file FilesViewModel.cs and add this code:

namespace MvcElectron.Models;

public class FilesViewModel
{
    public List Files => new DirectoryInfo(Path)
        .GetFiles()
        .OrderByDescending(f => f.Length)
        .Take(15)
        .ToList();
        
    public string Path => Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
}

In the HomeController.cs file, we must pass the ViewModel to the view:

public IActionResult Index()
{
    return View(new FilesViewModel());
}

We must change the page Index.cshtml with this code:

@model FilesViewModel
@{
    ViewData["Title"] = "Files list";
}

<div>
    <h3>Files List @Model?.Path</h3>
    <table class="table table-sm table-striped">
        <thead class="thead-dark">
            <tr>
                <th scope="col" class="col-sm-4">Name</th>
                <th scope="col" class="col-sm-3">Size</th>
                <th scope="col" class="col-sm-4">Last Write</th>
                <th scope="col" class="col-sm-1"></th>
            </tr>
        </thead>
        <tbody>
            @foreach (var item in Model?.Files ?? new List<FileInfo>())
            {
                <tr class="align-middle">
                    <td scope="col" class="col-sm-4">@item.Name</td>
                    <td scope="col" class="col-sm-3">@item.Length</td>
                    <td scope="col" class="col-sm-4">@item.LastWriteTime</td>
                    <td scope="col" class="col-sm-1">
                        <button type="button" class="btn btn-primary" onclick="location.href='@Url.Action("DeleteFile","Home", new {fileName=item.Name})'">
                            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
                            class="bi bi-trash" viewBox="0 0 16 16">
                                <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0
                                V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
                                <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1
                                H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1
                                V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
                            </svg>
                        </button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
</div>

It will show the files in a table, ordered (the largest come first):

To delete a file, we must add a new action to the Home controller:

public IActionResult DeleteFile(string fileName)
{
    var viewModel = new FilesViewModel();
    viewModel.DeleteFile(fileName);
    return RedirectToAction("Index");
}

The FilesViewModel class must be changed to delete the file:

namespace MvcElectron.Models;

public class FilesViewModel
{
    public List<FileInfo> Files { get; private set; }

    public FilesViewModel()
    {
        Files = GetFiles();
    }

    private List<FileInfo> GetFiles()
    {
        return new DirectoryInfo(Path)
        .GetFiles()
        .OrderByDescending(f => f.Length)
        .Take(15)
        .ToList();
    }

    public string Path => Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);

    public void DeleteFile(string fileName)
    {
        var filePath = Path + "\\" + fileName;
        if (System.IO.File.Exists(filePath))
        {
            System.IO.File.Delete(filePath);
        }
        Files = GetFiles();
    }
}

Once you do that, you can click on the button to delete a file and the files list is refreshed. There is only one thing to do, now: generate an executable, so we can distribute the file. To do that, we need to use the command:

electronize build /target win

With that, Electron will build a package for Windows (if you need other platforms, you should change the target). It will generate an install file (the target file is pointed in the output), that can be installed and, then, you can run the program.

As you can see, you can transform your Asp.Net MVC app into a native file that can access the computer’s resources as any native app. This is a multi-platform app, you can generate it for Windows, Linux or Mac, with no change at all.

The full code for the app is at https://github.com/bsonnino/MvcElectron

 

You have developed your Asp.Net Core MVC, it’s working fine and then you want to take it into the next level: to make it installable and allow it to be distributed in the platform store. But a web app is a web app and, as so, it should run in the browser. That would be true some years ago, but not anymore.

There are several ways to transform your web app into an installable app, without rewriting it. One easy way to do it is to transform it into a Progressive Web App (PWA). A PWA is an app that allows to be installed in the hosting OS, is multiplatform, and doesn’t need the browser to be ran. That seems too good to be true, but there is more: it can also use the resources of the hosting OS, as a native app and can be sent and distributed in the platform store.

That seems very nice, no ? And what are the requirements to make a PWA ?

  • It should be launchable and installable
  • It should be fast, even on slow networks
  • It should serve data using HTTPS, not HTTP
  • It should offer offline experience
  • It should run in all browsers
  • It should have responsive design

The full checklist for a PWA can be found here. There is a resource to audit your app, it’s called Lighthouse and can be accessed by opening the Dev Tools (F12):

If you click on Generate report, it will scan your website to see if you have everything to run your app as a PWA:

Ok. Now we know how to check if our website is ready for being a PWA, but what does it take to transform it into a PWA? Basically, two things: a Service Worker and a manifest file.

The Service worker is a script that runs separately from your web page and offers extra functionality, like push notifications and background sync. You can read more about it here.

The app manifest is a json file that will add metadata information for your app. in a way that it can be used to install the app. Sound simple, no ? Well, it’s not that difficult, we’ll do it right now.

The first step is to create a service worker, we’ll use the tutorial described in  https://developers.google.com/web/tools/workbox/guides/get-started. Let’s create a file named serviceWorker.js and add it to the wwwroot folder:

console.log('Hello from serviceWorker.js');

Then, we’ll register it in the end of the _Layout.cshtml file:

<script>
    // Check that service workers are supported
    if ('serviceWorker' in navigator) {
        // Use the window load event to keep the page load performant
        window.addEventListener('load', () => {
            navigator.serviceWorker.register('/serviceWorker.js');
        });
    }
</script>

Once you do that, you can open the dev tools and, in the console, you will see the message:

You can also check in the Application tab, under Service Workers, that it’s up and running:

This works, but it’s not very useful. You can create your own service worker by using Workbox, a tool developed by Google, to develop something that matches your needs. For now, we’ll use workbox cli to generate the service worker (for that, you will have to have npm installed in your machine).  Install the workbox cli with:

npm install workbox-cli --global

Then run the wizard, to generate a configuration file:

workbox wizard

Once the wizard has ran, you can generate the service worker with:

workbox generateSW workbox-config.js

That will generate a sw.js file in wwwroot. You have to change the register procedure in _Layout.cshtml to reflect this change:

<script>
    // Check that service workers are supported
    if ('serviceWorker' in navigator) {
        // Use the window load event to keep the page load performant
        window.addEventListener('load', () => {
            navigator.serviceWorker.register('/sw.js');
        });
    }
</script>

 

Once you do that, the next step is to add a manifest file to your app. The easiest way to do it is to use a manifest generator, like this one:

You will need an icon for the app. I went to https://freeicons.io and downloaded an image for my icon and added it to the manifest generator. Then I downloaded a zip with the manifest file and the icons for the app. The manifest file is something like this:

{
    "theme_color": "#3558f6",
    "background_color": "#2a10b1",
    "display": "standalone",
    "scope": "/",
    "start_url": "/",
    "name": "MyMVCApp",
    "short_name": "mymvcapp",
    "description": "Sample MVC PWA app",
    "icons": [
        {
            "src": "/images/icon-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/images/icon-256x256.png",
            "sizes": "256x256",
            "type": "image/png"
        },
        {
            "src": "/images/icon-384x384.png",
            "sizes": "384x384",
            "type": "image/png"
        },
        {
            "src": "/images/icon-512x512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

This manifest file must be put on the wwwroot folder of the app with the name of manifest.json and the images, on wwwroot/images folder.

Your PWA is ready. You can run it and see if it’s installable, by checking if this icon is present in Edge (there should be a similar one in the other browsers):

Clicking on it, you will have an install prompt:

If you choose to install it, you will have an app that will run in a window, will have an icon (which you can pin in the taskbar in Windows) and an icon in the start menu:

This app is multi-platform, meaning that you can install it on Linux, Mac or any phone platform. If you press F12 to open the Dev Tools, they will open and then, in the Application tab, you can see the manifest data:

We can check the app in Lighthouse for the PWA compatibility:

From it, you can see three errors

  • Redirects HTTP traffic to HTTPS Error!
  • Does not provide a valid apple-touch-icon
  • Manifest doesn’t have a maskable icon
If you see the details, you can see they are easy to fix:
The second one needs just adding a non transparent icon to the images folder and add this line in the head section of _Layout.cshtml:
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">

This will provide a valid apple-touch-icon

The third is just provide a maskable icon for Android devices. This is done adding a purpose in the icon on the manifest:

"icons": [
  {
    "src": "/images/icon-192x192.png",
    "sizes": "192x192",
    "type": "image/png",
    "purpose" :  "maskable" 
  },

The first error is already fixed: on the Startup.cs, we already have this:

app.UseHttpsRedirection();

This will allow redirection from http to https, but we are using a port, and we cannot use the same port for http and https (every protocol must have its own port). To test it, we should have it stored in a web site and use the default ports (80 for http and 443 for https). That way we wouldn’t need to use the ports in the address and the test could be done with no problems. If we have IIS installed in our machine, then the problem is solved.

If we don’t have it, we still have an alternative: launch the app in the correct ports. One way to do it is to use the dotnet cli and launch Kestrel with the correct ports. This is done with this command line:

dotnet run --urls "http://localhost:80;https://localhost:443"

It will lauch the app with the default ports (that won’t run if you already have some web server listening in those ports), so we can lauch our app with http://localhost or https://localhost.

Once you make these changes and run Lighthouse again, you will get:

Now the app passes the PWA test. But this is not everything that a PWA can do. It can interact with the OS, like a native app, and we will see how to do it.

The first thing that can be done is to add a shortcut to the taskbar. This is done in the manifest. by adding a Shortcuts clause. We will add a shortcut for the privacy policy:

"shortcuts": [
  {
    "name": "Privacy policy",
    "short_name": "Privacy",
    "description": "View the privacy policy",
    "url": "/Home/Privacy",
    "icons": [
      {
        "src": "/images/icon-lock.png",
        "sizes": "128x128"
      }
    ]
  }
]

You will need to add an icon in the images folder, named icon-lock.png. Once you add these and reinstall the app, when you right click in the taskbar icon, you will see a new menu option:

If you click in this new menu option, it will open a new window with the privacy policy.

One other thing that you can do with PWAs is to access the file system. In the Views\Home folder, create a new FileSystem.cshtml file:

@{
    ViewData["Title"] = "File System";
}
<div class="container">
    <pre id="textBox" class="pre-scrollable" contenteditable="true">Click on the button to load a file</pre>
    <button class="btn btn-secondary" onclick="loadFile()">Load file</button>
    <button class="btn btn-secondary" onclick="saveFile()">Save file</button>
</div>
<script>
    loadFile = async () => {
        const [handle] = await window.showOpenFilePicker();
        const file = await handle.getFile();
        const text = await file.text();
        $("#textBox").text(text);
    }
    saveFile = async () => {
        const options = {
            types: [
                {
                    description: 'Text Files',
                    accept: {
                        'text/plain': ['.txt'],
                    },
                },
            ],
        };
        const handle = await window.showSaveFilePicker(options);
        const writable = await handle.createWritable();
        await writable.write($("#textBox").text());
        await writable.close();
    }

</script>

This file will show a text area with two buttons, for load and save a file. The loadFile function will open a OpenFilePicker, get a file handle, open the file and get its text and load the text area. The saveFile function will open a SaveFilePicker, create a writable file and save the textarea text to it. The next step is to add the link to this page in the main menu. In _Layout.cshtml, add this between the Home and the Privacy links:

<li class="nav-item">
    <a class="nav-link text-dark" asp-area="" asp-controller="Home" 
      asp-action="FileSystem">File System</a>
</li>

When you run the project, you will have a new option that will take you to the file system page:

Now, you have the option to open and save the files to your file system.

Another thing that can be done is to share data with other apps. In the File System page, add a new button:

<button class="btn btn-secondary" onclick="shareData()">Share</button>

The code for the button is:

shareData = async () => {
    const data = {
        title: 'Sharing data with PWA',
        text: $("#textBox").text(),
        url: 'https://localhost:5001',
    }
    try {
        await navigator.share(data)
    } catch (err) {
        alert('Error: ' + err);
    }
}

This code will load the text in the text area and will share it with the registered apps. When you run this app and click the Share button, you will see something like this:

 

 

If you choose the Mail application, it will open the mail app with the text data:

One other feature that can be enabled is to run the app as a tabbed app. For that, the only thing to do is to enable that, opening the url edge://flags and enabling Desktop PWA tab strips:

When you run the installed app, it will open as a tabbed window:

These are some features available for the PWAs, and more are being added every day. You can check some experimental features in Edge here

All the source code for this project is at https://github.com/bsonnino/MvcPwa

On the latest Build, a new thing was released, but went almost unnoticed: the new package manager, WinGet (https://github.com/microsoft/winget-cli).

You may say “Another package manager ?”, and you are right: we already have Chocolatey (https://chocolatey.org/) and the Windows Store, but this one is official and uses the command line, so it’s easier to use for power users and you can create PowerShell scripts to automate installations: just create a Powershell script and run it on a new machine to have it fully configured.

To install it, you must  have Windows App Installer installed in your machine. If you don’t have it, you can get it from the Windows Store:

You can download and install the Windows Package Manager from the Winget GitHub page or just install the latest version directly. Then, you will be able to use Winget to manage your packages.

You can open a Command Line, PowerShell or Terminal (BTW, this is very nice. If you haven’t installed it, you can use WinGet to install it) and use it. If you type Winget in the command line, you will have a list of commands available:

From the list, you can see the first things that come to view: install and uninstall. With Winget, you can easily install and uninstall apps in your machine. One note, here: if the operation needs admin rights, you will be asked for elevation, unless you are already running on an elevated command line prompt.

To see what’s installed in your machine, just use Winget list and it will show you all the apps installed in your machine, both Desktop apps and Store apps:

One extra thing that is shown there is, if the app is available on Winget, it will show you if the app is up-to-date or there is an updated version of the app. For example, it’s showing that there is a newer version of the excellent markdown editor, MarkdownMonster (if you edit markdown files, I really recommend checking it). I can update it with

winget upgrade "Markdown Monster"

To get a list of all apps that can be upgraded, you can type

winget upgrade

You can upgrade all available packages with

winget upgrade --all

If you want to install a package, you can search to see if it’s in Winget’s repository with

winget search

That will show you all the packages available in the repository

You can install any of them using Winget install and use the name or the id of the app. For example, you can install version 3.9 of Python using

winget install "python 3.9"

In this case, I used the quotes for the name, because the name has spaces in it.

If you want to know details of a package, you can use the Show command:

winget show Powertoys

You will have the description, publisher, website and other information of the package

All the packages are obtained from registered repositories, which you can manage with the winget source command. To list the registered repositories, you can use

winget source list

At the moment, you can’t set up your own repositories, but I think that in the future that will be possible and will open the possibility for an enterprise to create its own repository of registered apps, available for all its members.

As Winget is still in development, there may be some experimental features that may be disabled or enabled. To list the experimental features available, you can use the Winget features command:

You can enable the experimental features by configuring the settings for Winget. hen you type Winget settings, the default editor for json files is opened with the settings file, where you can edit the preferences for Winget. you can check the preferences for the current version at https://github.com/microsoft/winget-cli/blob/master/doc/Settings.md

As you can see from my settings file, I have enabled the Rainbow progress bar, set the install preferences to User and enabled the store experimental feature.

One nice feature is that you can export the installed apps in your machine to a file that can be used in another machine to reinstall the same apps. But there is a catch: only files in Winget repository will be installed. That way, you may not have your machine fully configured, but that will be better when the repository has more programs.

Conclusions

Winget is a very promising package manager, it still doesn’t have a huge repository of packages (there are currently about 1400 apps there) but, if you have created an app, you can submit yours here. There are still some features I’d like to have, but let’s see what the future will bring us:

  • Knowing more information on an installed package: Description, manufacturer, website, installation folder
  • Search and Installed apps ordered by Name

One criticism about Asp.Net web api is the ceremony to create a simple web api. This criticism has some reason: when you create a bare-bones web api with this command:

dotnet new webapi

You will get something like this:

As you can see, there is a lot of plumbing going on in Program.cs and Startup.cs and the real meat is in Controllers\WeatherForecastController.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace WebApi.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}

Even that has a lot going on: the ApiControllerRoute and HttpGet attributes, besides the code (well, that’s needed in any platform, anyway :-)). For comparison, we could create the same web api using node.js with Express with this code:

const express = require("express");

const app = express();

const nextElement = (n) => Math.floor(Math.random() * n);

const summaries = [
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
];

app.get("/weatherforecast", (req, res, next) => {

    const forecasts = [...Array(5).keys()].map(i => (
        {
            "date": new Date() + i,
            "temperatureC": nextElement(75) - 20,
            "summary": summaries[nextElement(summaries.length)]
        }));
    
    res.json(forecasts);
});

app.listen(3000, () => {
    console.log("Server running on port 3000");
});

 

As you can see, there is a lot less code for the same api. The Express library takes care of setting up the server, listening to the port and mapping the route. The response for the request is the same as the one generated by the Asp.Net api.

To fix this issue, David Fowler, a Microsoft software architect designed a way to create Asp.Net web apis with low code. In fact, you can do it with a comparable amount of code than with Node. It’s called FeatherHTTP and it’s available here.

To use it, you must install the template and you can use it with dotnet new.

To install the template you can use this command:

dotnet new -i FeatherHttp.Templates::0.1.83-alpha.g15473de7d1 --nuget-source https://f.feedz.io/featherhttp/framework/nuget/index.json

When you do it, the option feather appears in dotnet new:

 

You can now create a new project with the command:

dotnet new feather -n FeatherApi

When the project is created, you can see that it’s much lighter than the standard code: there is only the csproj file and Program.cs with this code:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var app = WebApplication.Create(args);

app.MapGet("/", async http =>
{
    await http.Response.WriteAsync("Hello World!");
});

await app.RunAsync();

Much smaller, no? As you can see, it uses the new C#9 feature “Top level statements” to reduce a little bit the code. We can change the code to have the same output of the standard code:

using System;
using System.Linq;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var app = WebApplication.Create(args);
var Summaries = new[]
{
   "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", async http =>
{
    var rng = new System.Random();
    var weatherForecast = Enumerable.Range(1, 5)
        .Select(index => new WeatherForecast(
            DateTime.Now.AddDays(index), rng.Next(-20, 55), 
            Summaries[rng.Next(Summaries.Length)])).ToArray();
    http.Response.ContentType = "application/json";    
    await http.Response.WriteAsync(JsonSerializer.Serialize(weatherForecast));
});

await app.RunAsync();

public record WeatherForecast(DateTime Date, int TemperatureC,string Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Now we have something comparable to Node! I’ve used here another feature of C#9, Records, to reduce a little the code size.

As we can see, with FeatherHttp, we can reduce the code size for an Asp.Net web api app, but can we still use it for a more complex API ?

We’ll try to reproduce the code used in my HttpRepl article here.

The first step is to copy the Customer class to the project. We’ll convert it to a record:

public record Customer(string CustomerId, string CompanyName,
  string ContactName, string ContactTitle, string Address,
  string City, string Region, string PostalCode, string Country,
  string Phone, string Fax);

Then we’ll copy the repository:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Xml.Linq;

namespace CustomerApi.Model
{
    public class CustomerRepository
    {
        private readonly IList<Customer> customers;

        public CustomerRepository()
        {
            var doc = XDocument.Load("Customers.xml");
            customers = new ObservableCollection<Customer>(
                doc.Descendants("Customer").Select(c => new Customer(
                    GetValueOrDefault(c, "CustomerID"), GetValueOrDefault(c, "CompanyName"),
                    GetValueOrDefault(c, "ContactName"), GetValueOrDefault(c, "ContactTitle"),
                    GetValueOrDefault(c, "Address"), GetValueOrDefault(c, "City"),
                    GetValueOrDefault(c, "Region"), GetValueOrDefault(c, "PostalCode"),
                    GetValueOrDefault(c, "Country"), GetValueOrDefault(c, "Phone"),
                    GetValueOrDefault(c, "Fax")
            )).ToList());
        }

        #region ICustomerRepository Members

        public bool Add(Customer customer)
        {
            if (customers.FirstOrDefault(c => c.CustomerId == customer.CustomerId) == null)
            {
                customers.Add(customer);
                return true;
            }
            return false;
        }

        public bool Remove(Customer customer)
        {
            if (customers.IndexOf(customer) >= 0)
            {
                customers.Remove(customer);
                return true;
            }
            return false;
        }

        public bool Update(Customer customer)
        {
            var currentCustomer = GetCustomer(customer.CustomerId);
            if (currentCustomer == null)
                return false;
            var index = customers.IndexOf(currentCustomer);
            currentCustomer = new Customer(customer.CustomerId, customer.CompanyName,
              customer.ContactName, customer.ContactTitle, customer.Address, customer.City,
              customer.Region, customer.PostalCode, customer.Country, customer.Phone, customer.Fax);
            customers[index] = currentCustomer;
            return true;
        }

        public bool Commit()
        {
            try
            {
                var doc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
                var root = new XElement("Customers");
                foreach (Customer customer in customers)
                {
                    root.Add(new XElement("Customer",
                                          new XElement("CustomerID", customer.CustomerId),
                                          new XElement("CompanyName", customer.CompanyName),
                                          new XElement("ContactName", customer.ContactName),
                                          new XElement("ContactTitle", customer.ContactTitle),
                                          new XElement("Address", customer.Address),
                                          new XElement("City", customer.City),
                                          new XElement("Region", customer.Region),
                                          new XElement("PostalCode", customer.PostalCode),
                                          new XElement("Country", customer.Country),
                                          new XElement("Phone", customer.Phone),
                                          new XElement("Fax", customer.Fax)
                                 ));
                }
                doc.Add(root);
                doc.Save("Customers.xml");
                return true;
            }
            catch (Exception)
            {
                return false;
            }
        }

        public IEnumerable<Customer> GetAll() => customers;

        public Customer GetCustomer(string id) => customers.FirstOrDefault(c => string.Equals(c.CustomerId, id, StringComparison.CurrentCultureIgnoreCase));

        #endregion

        private static string GetValueOrDefault(XContainer el, string propertyName)
        {
            return el.Element(propertyName) == null ? string.Empty : el.Element(propertyName).Value;
        }
    }
}

One note here: as we declared Customer as a Record, it’s immutable, so we cannot simply replace its data with the new data in the Update method. Instead, we create a new customer and replace the current one with the newly created in the customers list.

We also need the customers.xml file that can be obtained at https://github.com/bsonnino/HttpRepl/blob/main/Customers.xml and add it to the project by adding this clause in the csproj file:

<ItemGroup >
  <None Update="customers.xml" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

Now, we will change the main program to create the API:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using CustomerApi.Model;
using System.Text.Json;
using System.Threading.Tasks;

var app = WebApplication.Create(args);

app.MapGet("/", GetAllCustomers);
app.MapGet("/{id}", GetCustomer);
app.MapPost("/", AddCustomer);
app.MapPut("/", UpdateCustomer);
app.MapDelete("/{id}", DeleteCustomer);

await app.RunAsync();

async Task GetAllCustomers(HttpContext http)
{
    var customerRepository = new CustomerRepository();
    http.Response.ContentType = "application/json";
    await http.Response.WriteAsync(JsonSerializer.Serialize(customerRepository.GetAll()));
}

async Task GetCustomer(HttpContext http)
{
    if (!http.Request.RouteValues.TryGet("id", out string id))
    {
        http.Response.StatusCode = 400;
        return;
    }
    var customerRepository = new CustomerRepository();
    Customer customer = customerRepository.GetCustomer(id);
    if (customer != null)
    {
        http.Response.ContentType = "application/json";
        await http.Response.WriteAsync(JsonSerializer.Serialize(customer));
    }
    else
        http.Response.StatusCode = 404;
    return;
}

async Task AddCustomer(HttpContext http)
{
    var customer = await http.Request.ReadFromJsonAsync<Customer>();
    if (string.IsNullOrWhiteSpace(customer.CustomerId))
    {
        http.Response.StatusCode = 400;
        return;
    }
    var customerRepository = new CustomerRepository();
    if (customerRepository.Add(customer))
    {
        customerRepository.Commit();
        http.Response.StatusCode = 201;
        http.Response.ContentType = "application/json";
        await http.Response.WriteAsync(JsonSerializer.Serialize(customer));
        return;
    }
    http.Response.StatusCode = 409;
    return;
}

async Task UpdateCustomer(HttpContext http)
{
    var customer = await http.Request.ReadFromJsonAsync<Customer>();
    if (string.IsNullOrWhiteSpace(customer.CustomerId))
    {
        http.Response.StatusCode = 400;
        return;
    }
    var customerRepository = new CustomerRepository();
    var currentCustomer = customerRepository.GetCustomer(customer.CustomerId);
    if (currentCustomer == null)
    {
        http.Response.StatusCode = 404;
        return;
    }
    if (customerRepository.Update(customer))
    {
        customerRepository.Commit();
        http.Response.ContentType = "application/json";
        await http.Response.WriteAsync(JsonSerializer.Serialize(customer));
        return;
    }
    http.Response.StatusCode = 204;
    return;
}


async Task DeleteCustomer(HttpContext http)
{
    if (!http.Request.RouteValues.TryGet("id", out string id))
    {
        http.Response.StatusCode = 400;
        return;
    }
    var customerRepository = new CustomerRepository();
    var currentCustomer = customerRepository.GetCustomer(id);
    if (currentCustomer == null)
    {
        http.Response.StatusCode = 404;
        return;
    }
    if (customerRepository.Remove(currentCustomer))
    {
        customerRepository.Commit();
        http.Response.StatusCode = 200;
        return;
    }
    http.Response.StatusCode = 204;
    return;
}

We are mapping the routes for all the desired actions. For each mapping, we’ve created a method to use when the corresponding route is being called:

  • For GetAll, we ar using the same method we’ve used with the WeatherForecast, getting all customers, serializing the array into a json string and writing the response
  • To get just one customer, where the id is in the route, we get the id with http.Request.RouteValues.TryGet, get the customer and write the response
  • To add and update the customer, we must get it from the body with await http.Request.ReadFromJsonAsync<Customer>(); Once we get it, we can add or update the customer repository
  • Delete is similar to getting just one customer, but we delete it from the repository, instead of returning it.

You can test the API by using the same methods we’ve used in the HttpRepl article, the API will be the same, but in this case, I’m not using the customers route. Another minor issue is that Swagger is still not available in FeatherHttp (https://github.com/featherhttp/framework/issues/34), but this doesn’t change the functionality at all.

FeatherHttp is not production ready, it’s still in alpha, but it shows a direction to lightweight Asp.Net web api applications. As you can see, all the ceremony from Asp.Net web api can be removed and you can finish with an app as small as a Node one, but written in C#.

All the source code for this article is at https://github.com/bsonnino/FeatherHttp

 

 

 

 

Introduction

Office 2007 brought many significant changes for this app suite. The most visible was the Ribbon, an new user interface that eases the use of the applications.

Another change, less visible, allows the integration of Office applications with a large variety of programs: the new file format. Until the previous version, the file format was proprietary: when you wanted to open or save an Office document in our applications, you should use Ole Automation, what required that Office was installed in the client’s machine, or try to discover the internal file format, which wasn’t documented and could be changed at any time.

The new file format, besides being documented, is based in open standards, thus allowing that any aplication for any platform, written in any language to open or create Office 2007 files. This new standard, named OpenXML, is based on the zip packaging and XML files. It creates smaller files and allows other applications to open and change these files.

This opens a lot of possibilities:

  • Programs to index and search text from the files in the machine
  • Programs for batch generation of documents, based on databases and templates
  • Programs for batch text replacement
  • Simple text editors that generate Office files
  • Spreadsheet generation using data from many sources

In this article, we will show the new file format and how we can read and crete them using Delphi, with no need to install Ofiice.

Analyzing an OpenXML file

Any OpenXML file is, in fact, a zip file with many folders and XML files. We can see that in practice, by creating a Word file with some text and save it. If we rename this file to zip, we can open it with any program that can open zip files:

As you can see in the figure above, the file contains in the root three directories,  _rels, docProps and word and a file, [Content_Types].xml. This directory structure is created by Word and it’s not obligatory to maintain it. The files location is in the .rels file, located in the  _rels folder. This file contais the relations between the package and the files in the upper level. The following code shows the rels file from the example:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
   <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
   <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" Target="docProps/thumbnail.wmf"/>
   <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
   <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
 </Relationships>

Analyzing this file, we see the following:

  • The core properties are in the docProps/core.xml file.
  • The thumbnail is in the docProps/thumbnail.wmf file.
  • The main document is in word/document.xml.
  • The extended-properties are in docProps/app.xml.

Besides that, we can see that the word directory contains a _rels subdirectory, which contains the relations for the document. In the file document.txt.rels, we find the following relations:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">   
  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" Target="webSettings.xml"/>   
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>   
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>   
  <Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>   
  <Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/>   
  <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/> 
</Relationships>

Here you can find the relations for the document. We see that the styles used in the document are in styles.xml and any image in the document is in the media folder. That way, we can access any part of the document.

Based on these informations, we can open the files that are in the OpenXML package. Now, we will create a small Delphi program that opens an Office file and lists its properties in a component TValueListEditor.

Accessing OpenXml files

To open OpenXml files, we need to divide our program in the following parts:

  • Open the OpenXml package with a component that allows to read and write zip files
  • Open the .rels file and read the relations, extracting the parts that interest us
  • Access the parts, executing what we want

To open the zip files, we will use the TZipFile component, which is available in Delphi since version XE2. This component allows to manipulate zip files in a relatively simple way. You should create a new Delphi project and add to the main form a button, an OpenDialog and a Memo.

Configure the Caption property of the button to Open. Configure the Filter property of the OpenDialog to “Word Files (*.docx, *.docm)|*.docx;*.docm| Excel Files(*.xlsx, *.xlsm)|*.xlsx;*.xlsm| Powerpoint Files(*.pptx, *.pptm)|*.pptx;*.pptm”.

On the OnClick event handler of the button, add the following code:

procedure TMainFrm.Button1Click(Sender: TObject);
var
  ZipStream: TStream;
  XmlNode: IXMLNode;
  i: Integer;
  AttType: String;
  ZipFile: TZipFile;
  LocalHeader: TZipHeader;
begin
  if OpenDialog1.Execute then begin
    ZipFile := TZipFile.Create();
    try
      ZipFile.Open(OpenDialog1.FileName, TZipMode.zmRead);
      try
        ZipFile.Read('_rels/.rels', ZipStream, LocalHeader);
        ZipStream.Position := 0;
        XMLDocument1.LoadFromStream(ZipStream);
        Memo1.Text := XMLDoc.FormatXMLData(XMLDocument1.XML.Text);
      finally
        ZipStream.Free;
      end;
    finally
      ZipFile.Close();
      ZipFile.Free;
    end;
  end;
end;

If the user chooses a file, we open the file with the TZipFile and extract the .rels file to a stream and load the lines of the memo with this stream formatted with the FormatXMLData. The following figure shows the result of this operation:

Once we have the .rels file, we must read it and interpret the relations. We could use the functions to read text files and interpret the document, but this is not the best way to do this operation. The ideal is to use a component to read XML files, like the TXMLDocument component that comes with Delphi.

Put two TXMLDocument components and a TValueListEditor on the form. Modify the TileCaptions property of the TValueListEditor to Property/Value. On the OnClick handler of the button modify the code to this one:

procedure TMainFrm.Button1Click(Sender: TObject);
var
  ZipStream: TStream;
  XmlNode: IXMLNode;
  i: Integer;
  AttType: String;
  ZipFile: TZipFile;
  LocalHeader: TZipHeader;
begin
  if OpenDialog1.Execute then begin
    ZipFile := TZipFile.Create();
    try
      ZipFile.Open(OpenDialog1.FileName, TZipMode.zmRead);
      ZipFile.Read('_rels/.rels', ZipStream, LocalHeader);
      try
        ZipStream.Position := 0;
        XMLDocument1.LoadFromStream(ZipStream);
        Memo1.Text := XMLDoc.FormatXMLData(XMLDocument1.XML.Text);
        ValueListEditor1.Strings.Clear;
        for i := 0 to XMLDocument1.DocumentElement.ChildNodes.Count - 1 do begin
          XmlNode := XMLDocument1.DocumentElement.ChildNodes.Nodes[i];
          AttType := ExtractFileName(XmlNode.Attributes['Type']);
          if AttType.EndsWith('core-properties') or
             AttType.EndsWith('extended-properties') then
            ReadProperties(ZipFile, XmlNode.Attributes['Target']);
        end;
      finally
        ZipStream.Free;
      end;
    finally
      ZipFile.Close();
      ZipFile.Free;
    end;
  end;
end;

We load the stream in XMLDocument1 and process the nodes, to find the ones with the types we want (core-properties or extended-properties). When we find them, we pass the name of the file (which is in the Target attribute) to the ReadProperties function, which will read the property file and add them to the ValueListEditor. The ReadProperties  function is:

procedure TMainFrm.ReadProperties(ZipFile: TZipFile; const FileName: String);
var
  ZipStream: TStream;
  i: Integer;
  XmlNode: IXMLNode;
  LocalHeader: TZipHeader;
begin
  ZipFile.Read(FileName, ZipStream, LocalHeader);
  try
    ZipStream.Position := 0;
    XMLDocument2.LoadFromStream(ZipStream);
    for i := 0 to XMLDocument2.DocumentElement.ChildNodes.Count - 1 do begin
      XmlNode := XMLDocument2.DocumentElement.ChildNodes.Nodes[i];
      try
        ValueListEditor1.InsertRow(XmlNode.NodeName, XmlNode.NodeValue, True);
      except
        On EXMLDocError do;
        On EVariantTypeCastError do
          ValueListEditor1.InsertRow(XmlNode.NodeName, '', True);
      end;
    end;
  finally
    ZipStream.Free;
  end;
end;

 

This function is similar with the previous one. We will read the properties file in the second TXMLDcoument and insert a line in the ValueListEditor for each property found. We treat here two types of exceptions: EXMLDocError, which can be raised when the type of the information is not a single type, like a string or an integer and EVariantTypeCastError, which happens when the value is null. This way, we add the properties on the list, like in the next figure:

As we can see, the access to the data of an OpenXml file is relatively simple and can be made using components available in Delphi, but this isn’t everything that can be done: as we are working with zip and xml files, using open technology, we can also modify the files, using the same techniques. In the next section, we’ll see how to create a file from our data.

Creating an OpenXml file

To create an OpenXml file, we need to create some files that will be added to the package. The package should contain at least three files:

  • [Content_Types].xml
  • _rels/.rels
  • xml

It’s not necessary to create a folder structure like the one created by Word, we just need to point the location of the files in the .rels file. When adding new functionalities, like images, headers, themes and styles, we must add new files to add these parts to the document. Initially, we will create a simple file, to show the file generation process and then, we will show how to create a more complex file.

Create a new project and place a Label, a Memo and a button on the Form. Change the Caption property of the Label to Text :, the Caption property of the Button to Create and clear the Lines property of the Memo.

Place an XMLDocument component. In the button’s OnClick event, place the following code:

procedure TMainFrm.Button1Click(Sender: TObject);
var
  zipFile: TZipFile;
  contentTypes: TStream;
  rels: TStream;
  doc: TStream;
begin
  zipFile := TZipFile.Create();
  try
    zipFile.Open('SimpleFile.docx', TZipMode.zmWrite);
    contentTypes := CreateContentTypes();
    try
      zipFile.Add(contentTypes, '[Content_Types].xml');
    finally
      contentTypes.Free;
    end;
    rels := CreateRels();
    try
      zipFile.Add(rels, '_rels\.rels');
    finally
      rels.Free;
    end;
    doc := CreateDoc();
    try
      zipFile.Add(doc, 'word\document.xml');
    finally
      doc.Free;
    end;
  finally
    zipFile.Close();
    zipFile.Free;
  end;
end;

The program will create the necessary files, add the streams to the zip file and create a file with the name SimpleFile.docx. The function that creates the file [Content_Types.xml] is:

function TMainFrm.CreateContentTypes(): TStream;
var
  Root: IXmlNode;
  Type: IXmlNode;
  XMLDoc: IXmlDocument;
begin
  Result := TMemoryStream.Create();
  XMLDoc := CriaXml;
  Root := XMLDoc.addChild('Types',
    'http://schemas.openxmlformats.org/package/2006/content-types');
  Type := Root.addChild('Default');
  Type.Attributes['Extension'] := 'rels';
  Type.Attributes['ContentType'] :=
    'application/vnd.openxmlformats-package.relationships+xml';
  Type := Root.addChild('Default');
  Type.Attributes['Extension'] := 'xml';
  Type.Attributes['ContentType'] :=
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml';
  XMLDoc.SaveToStream(Result);
  Result.Position := 0;
end;

The function that create the relations file is:

function TMainFrm.CreateRels(): TStream;
var
  Root: IXmlNode;
  Rel: IXmlNode;
  XMLDoc: IXmlDocument;
begin
  Result := TMemoryStream.Create();
  XMLDoc := CriaXml;
  Root := XMLDoc.addChild('Relationships',
    'http://schemas.openxmlformats.org/package/2006/relationships');
  Rel := Root.addChild('Relationship');
  Rel.Attributes['Id'] := 'rId1';
  Rel.Attributes['Type'] :=
    'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument';
  Rel.Attributes['Target'] := 'word/document.xml';
  XMLDoc.SaveToStream(Result);
  Result.Position := 0;
end;

The code to write the document with the text entered in the Memo is:

function TMainFrm.CreateDoc(): TStream;
var
  Root: IXmlNode;
  XMLDoc: IXmlDocument;
begin
  Result := TMemoryStream.Create();
  XMLDoc := CriaXml;
  Root := XMLDoc.addChild('wordDocument',
    'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
  Root.addChild('body').addChild('p').addChild('r').addChild('t').NodeValue :=
    Memo1.Text;
  XMLDoc.SaveToStream(Result);
  Result.Position := 0;
end;

Here we just need to write a node inside the wordDocument root node: it is the body of the document, which has a paragraph (node p), a “run” (node r) and the text, which is the content of the Memo. When compiling and running the program, we can type some text in the Memo and click on the Create button. The docx file is created with the typed text.

Putting more information in the file

Once we know how to create our files, we can add more information to what is being created. We will now create an example that shows all the fonts available in the system. This document will be generated in landscape format, and we will put a header with three columns and the page number.

Create a new project and place a button and an XmlDocument Change the Caption property of the button to Create. In the button’s OnClick event, put:

procedure TMainFrm.Button1Click(Sender: TObject);
var
  ZipFile: TZipFile;
  MemStream: TMemoryStream;
begin
  ZipFile := TZipFile.Create();
  try
    ZipFile.Open('ComplexFile.docx', TZipMode.zmWrite);
    MemStream := TMemoryStream.Create();
    try
      CreateContentTypes(MemStream);
      ZipFile.Add(MemStream, '[Content_Types].xml');
      MemStream.Clear;
      CreateRels(MemStream);
      ZipFile.Add(MemStream, '_rels\.rels');
      MemStream.Clear;
      CreateDoc(MemStream);
      ZipFile.Add(MemStream, 'word\document.xml');
    finally
      MemStream.Free;
    end;
  finally
    ZipFile.Close();
    ZipFile.Free;
  end;
end;

The functions CreateRels and CreateContentTypes are the same as the previous routine. The function CreateDocument is the following:

procedure TMainFrm.CreateDocument(AStream: TStream);
var
  Root, Body, PgSz: IXMLNode;
  i: Integer;
  SectPr: IXMLNode;
  Header: IXMLNode;
begin
  LimpaXML;
  CreateHeader;
  Root := XMLDocument1.addChild('w:wordDocument');
  Root.DeclareNamespace('w',
    'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
  Body := Root.addChild('w:body');
  for i := 0 to Screen.Fonts.Count - 1 do
    AddFont(Body, Screen.Fonts[i]);
  
  XMLDocument1.SaveToStream(AStream);
  AStream.Position := 0;
end;

We will loop for the system fonts, calling the function AddFont, which will add the formatted text in the Document.xml file:

procedure TMainFrm.AddFont(Body: IXMLNode; NomeFonte: String);
var
  Fonte: IXMLNode;
  Run: IXMLNode;
  RunPr: IXMLNode;
begin
  Run := Body.addChild('w:p').addChild('w:r');
  RunPr := Run.addChild('w:rPr');
  Fonte := RunPr.addChild('w:rFonts');
  Fonte.Attributes['w:ascii'] := NomeFonte;
  Fonte.Attributes['w:hAnsi'] := NomeFonte;
  Fonte.Attributes['w:cs'] := NomeFonte;
  RunPr.addChild('w:sz').Attributes['w:val'] := 30;
  Run.addChild('w:t').NodeValue := NomeFonte;
  Run.addChild('w:tab');
  Run.addChild('w:t').NodeValue :=
    'The quick brown fox jumps over the lazy dog';
end;

For each font in the system, we add a paragraph and, in it, a Run. The Run must be formatted with the rPr element, placing the rFonts element and the font name as values of the ascii, hAnsi and cs attributes as children. We also changed the font size by adding the sz element. Then, we put the name of the font as text, adding the tab element to generate a tab and sample text. When running the program, we see that the list of fonts is generated in the document.

The next step is to have the document placed in landscape. To do this, we must add a sectPr element (section properties) to the end of the document, which indicates the formatting of the section. Place the following code at the end of CreateDocument, before the XMLDocument1.SaveToStream (AStream) line:

SectPr := Body.addChild('sectPr');
PgSz := SectPr.addChild('w:pgSz');
PgSz.Attributes['w:w'] := Round(297 / 25.4 * 1440);
PgSz.Attributes['w:h'] := Round(210 / 25.4 * 1440);
PgSz := SectPr.addChild('w:pgMar');
PgSz.Attributes['w:top'] := 1440;
PgSz.Attributes['w:bottom'] := 1440;
PgSz.Attributes['w:left'] := 720;
PgSz.Attributes['w:right'] := 720;
PgSz.Attributes['w:header'] := 720;
PgSz.Attributes['w:footer'] := 720;

In this code we add the element pgSz (Page size), giving the attributes w and h for the width and height of the page. These measurements are in twips (1/1440 of an inch), so we convert the page size from A4 to twips. Then, we put the pgMar element (Page margins), which determines the page margins and the position of the header and footer. When we run the program and open the document, we see that it is in landscape.

The last step is to place the header. We put the header in a separate file and, therefore, we must change all references so that this new document can be read.

Initially, we created a reference to the header in the section, as a child of sectPr. Place the following code in CreateDocument, after the line SectPr: = Body.AddChild (‘sectPr’):

Header := SectPr.addChild('w:headerReference');
Header.Attributes['w:type'] := 'default';
Header.Attributes['r:id'] := 'rId1';

To use the references, we must add a new namespace to the document. This is done by adding the following line after declaring the namespace in CreateDocument:

Root.DeclareNamespace ('r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships');

We created a reference rId1 in the document. We must then create a function that creates this relationship in the file word\_rels\document.xml.rels:

procedure TMainFrm.CreateDocRels(AStream: TStream);
var
  Root: IXMLNode;
  Rel: IXMLNode;
begin
  CleanXML;
  CreateHeader;
  Root := XMLDocument1.addChild('Relationships',
    'http://schemas.openxmlformats.org/package/2006/relationships');
  // Definição de relações
  Rel := Root.addChild('Relationship');
  Rel.Attributes['Id'] := 'rId1';
  Rel.Attributes['Type'] :=
    'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header';
  Rel.Attributes['Target'] := 'header1.xml';
  XMLDocument1.SaveToStream(AStream);
  AStream.Position := 0;
end;

This function is similar to the one that creates the package relationship. The function that creates the header in the file  header1.xml is:

procedure TMainFrm.CreateHeader(AStream: TStream);
var
  Root, Header, PTab: IXMLNode;
begin
  CleanXML;
  CriaCabecalho;
  Root := XMLDocument1.addChild('w:hdr');
  Root.DeclareNamespace('w',
    'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
  Header := Root.addChild('w:p');
  Header.addChild('w:r').addChild('w:t').NodeValue := 'Texto 1';
  PTab := Header.addChild('w:r').addChild('w:ptab');
  PTab.Attributes['w:relativeTo'] := 'margin';
  PTab.Attributes['w:alignment'] := 'center';
  PTab.Attributes['w:leader'] := 'none';
  Header.addChild('w:r').addChild('w:t').NodeValue := 'Texto 2';
  PTab := Header.addChild('w:r').addChild('w:ptab');
  PTab.Attributes['w:relativeTo'] := 'margin';
  PTab.Attributes['w:alignment'] := 'right';
  PTab.Attributes['w:leader'] := 'none';
  Header.addChild('w:fldSimple').Attributes['w:instr'] := 'PAGE \* MERGEFORMAT';
  XMLDocument1.SaveToStream(AStream);
  AStream.Position := 0;
end;

Here we create the header with text left-aligned, a tab to align the centered text and another tab to align the page number to the right.

The page number is given by the fldSimple element, using the instr attribute with the value PAGE\*MERGEFORMAT. After creating these functions, we must place the code to call them, at the end of the button’s OnClick event:

MemStream.Clear;
CreateDocRels(MemStream);
ZipFile.Add(MemStream, 'word\_rels\document.xml.rels');
MemStream.Clear;
CreateHeader(MemStream);
ZipFile.Add(MemStream, 'word\header1.xml');

Now, we need only to make a small change in [Content_Types].xml, adding the Override element, to show the type of the header1.xml file. Put the following code in the  CreateContentTypes, before the line XMLDocument1.SaveToStream(AStream):

Tipo := Root.addChild('Override');
Tipo.Attributes['PartName'] := '/word/header1.xml';
Tipo.Attributes['ContentType'] :=
  'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';

With that, our program is ready. When executing it, we generate a document similar to the one shown in the following figure:

Conclusions

The OpenXML format has great advantages for those who want to process and open Office files. As this format uses open technologies and is fully documented, we can access, change or even create Office files using any development tools (or even changing files manually), on any platform or language.

No proprietary APIs or special programs are required, which allows the information to be available to anyone who wants to access it. We show here how to manipulate Delphi files, noting that we use only standard Delphi components, using only zip and XML files.

The source code for this project is in https://github.com/bsonnino/OpenXmlDelphiEng

 

Introduction

One of the interesting things that C#9 brought is the introduction of Code Generators. When compiling the code, the C# compiler can generate extra code and add it to your project, thus complementing your code. This has a lot of possible uses:

  • Add an attribute to your code to generate boilerplate code: you can add an attribute to a private member and the code generator will add the property and the INotifyPropertyChanged to your class.
  • Discover the dependencies needed at compile time and wire them in the executable. This can improve execution time, because wiring the dependencies using reflection is very slow. In this case, the dependencies would be already set up.
  • Parse files and generate code for them: you could have a json file with data and the compiler would add the classes without the need of creating them manually. Once the file has changed (and thus the class structure), a new class would be created.

One interesting use of this feature was used by the Windows SDK Team for using the Win32 APIs in our C# code. Instead of using the traditional PInvoke (which you must go to http://pinvoke.net to get the signatures and structures, add them as external methods and call them), you can use C#/Win32, developed to simplify the usage of Win32 APIs.

Using C#/Win32

To use C#/Win32, you must have .NET 5.102 installed in your machine and you must be using Visual Studio 16.8 or newer. With these prerequisites, you can create a new console application in the command line with:

dotnet new console -o UseWin32

This line will create a new console project in the UseWin32  folder. Then you must add the CsWin32  NuGet package with:

dotnet add package Microsoft.Windows.CsWin32 --prerelease

Now you are ready to open the project in Visual Studio or Visual Studio Code and use it. This first project will  enumerate the files in the current directory. Yes, I know that .NET already has methods to do that, but we can also do it using Win32.

Create a new text file and name it NativeMethods.txt. In this file, add the names of the thre API functions that we need:

FindFirstFile
FindNextFile
FindClose

Now, we are ready to use these methods in our program. In Program.cs, erase everything and add this code:

using System;
using System.Linq;
using Microsoft.Windows.Sdk;

var handle = PInvoke.FindFirstFile("*.*", out var findData);
if (handle.IsInvalid)
    return;

We are using another feature of C#9, Top Level Statements. We can see that CsWin32 has generated a new class named PInvoke  and has added the FindFirstFile  method. If you hoveer the mouse over it, you will see something like this:

As you can see, it has added the function and  also added the documentation for it. If you right-click in the function and select Go to Definition, it will open the generated code:

We can then continue the code to enumerate the files:

using System;
using System.Linq;
using Microsoft.Windows.Sdk;

var handle = PInvoke.FindFirstFile("*.*", out var findData);
if (handle.IsInvalid)
    return;
bool result;
do
{
    Console.WriteLine(ConvertFileNameToString(findData.cFileName.AsSpan()));
    result = PInvoke.FindNextFile(handle, out findData);
} while (result);
PInvoke.FindClose(handle);

string ConvertFileNameToString(Span<ushort> span)
{
    return string.Join("", span.ToArray().TakeWhile(i => i != 0).Select(i => (char)i));
}

This code opens the enumeration with FindFirstFile. If the returned handle is invalid, then there are no files in the folder, so the program exits. Then, it will print the file name to the console and continue the enumeration until the last file, when it calls FindClose, to close the handle. The filename is returned as a structure named __ushort_260, that can be converted to a Span<ushort> with the AsSpan method. To convert this to a string, we use the ConvertFileNameToString method, that uses Linq to convert it to a string: it takes all the items until it finds a 0, converts them to an IEnumerable<char> and then uses string.Join to convert this to a string.

If you use this code, you will see that FindClose(handle) has an error. That’s because the FileClose function receives a parameter of type HANDLE, while the handle variable is of the FileCloseSafeHandle type and both are not compatible (FileCloseSafeHandle has a handle field, but it’s protected and cannot be used). The solution, in this case, is to dispose the handle variable, that will call FindClose. This code shows how this is done:

using var handle = PInvoke.FindFirstFile("*.*", out var findData);
if (handle.IsInvalid)
    return;
bool result;
do
{
    Console.WriteLine(ConvertFileNameToString(findData.cFileName.AsSpan()));
    result = PInvoke.FindNextFile(handle, out findData);
} while (result);

We are using here the C#8’s Using Statement, so we don’t need to use a block. When you run this code, you will see the enumeration of the files in the console:

Function callbacks

As you can see, there is no need to dig to use the Win32 APIs, but there are some APIs that are more complex and use a callback function. These can also be used in the same way. to see that, we can enumerate all resources in an executable. To do that, we load the executable with LoadLibraryEx. Once loaded, we use EnumerateResourceTypes to enumerate all resource types and, for every resource type, we enumerate the resources with EnumerateResourceNames.

Create a new project with

dotnet new console -o EnumerateResources

Then add the CsWin32 NuGet package with

dotnet add package Microsoft.Windows.CsWin32 --prerelease

Then, open the project in Visual Studio and add a new text file and name it NativeMethods.txt. Add these functions in the file:

LoadLibraryEx
FreeLibrary
EnumResourceTypes
EnumResourceNames

In Program.cs, erase all text and add this code:

using System;
using Microsoft.Windows.Sdk;

var hInst = PInvoke.LoadLibraryEx(@"C:\Windows\Notepad.exe",
    null, LoadLibraryEx_dwFlags.LOAD_LIBRARY_AS_DATAFILE);
try
{

}
finally
{
    PInvoke.FreeLibrary(hInst);
}

This code calls LoadLibraryEx to load Notepad. The LOAD_LIBRARY_AS_DATAFILE constant was changed to an enum. This function returns the Instance handle, that will be used to enumerate the resources. At the end, we free the instance using FreeLibrary.

Now, we’ll start to enumerate the resources with:

var hInst = PInvoke.LoadLibraryEx(@"C:\Windows\Notepad.exe",
    null, LoadLibraryEx_dwFlags.LOAD_LIBRARY_AS_DATAFILE);
try
{
    PInvoke.EnumResourceTypes(hInst, EnumerateTypes, 0);
}
finally
{
    PInvoke.FreeLibrary(hInst);
}

You can see that the second parameter in EnumerateResourceTypes is a callback function (which I don’t even know the signature :-)). Let’s see if Visual Studio will help us with this. We type Ctrl-. and it will propose us to create the method. We select it and it is created:

BOOL EnumerateTypes(nint hModule, PWSTR lpType, nint lParam)
{
    throw new NotImplementedException();
}

We can add our code to enumerate the types:

BOOL EnumerateTypes(nint hModule, PWSTR lpType, nint lParam)
{
    Console.WriteLine(PwStrToString(lpType));
    PInvoke.EnumResourceNames(hModule, lpType, EnumNames, 0);
    return true;
}

This code will write the resource type to the console and enumerate the resources for that type. We are using a function, PwStrToString to convert the resource name (a PWSTR) to a string:

string PwStrToString(PWSTR str)
{
    unsafe
    {
        return ((ulong)str.Value & 0xFFFF0000) == 0 ?
            ((ulong)str.Value).ToString() :
            str.AsSpan().ToString();
    }
}

This struct has a Value property, that can be an integer or a string. To know which of them we must use, we test against the high order byte and see if it’s empty. If it is, then the value is an integer and we convert it to a string. If not, we convert it to a Span and get the string from it. All this code must be marked as unsafe, as we are working with the pointers.

EnumerateResourceNames has a callback, which we get the signature in the same way we did before, by using Visual Studio refactoring:

BOOL EnumNames(nint hModule, PCWSTR lpType, PWSTR lpName, nint lParam)
{
    Console.WriteLine("  " + PwStrToString(lpName));
    return true;
}

Now the program is complete and we can run it to list all notepad’s resources:

As you can see, working with the Win32 API is much easier now, we don’t have to use custom P/Invokes, everything is at one place and all we have to do is to add the functions we want to the NativeMethods file. This is really a great and welcome improvement.

Ah, and if you want to know what those resource type numbers are, you can find them here.

  • 4 – Menu
  • 5 – Dialog
  • 6 – String
  • 9 – Accelerator
  • 16 – Version
  • 24 – Manifest

All the source code for this article is at https://github.com/bsonnino/UseWin32

 

With .NET 5.0, two small features were introduced to Asp.NET and were almost unnoticed: Open Api and HTTPRepl. Open Api is not something new, it’s been available for a long time, but it had to be included explicitly in a new project. Now, when you create a new project, it’s automatically included in the project and you can get the Api documentation using Swagger.

Now, when you create a new project with

dotnet new webapi

You will create a new WebApi project with a Controller, WeatherController, that shows 10 values of a weather forecast:

It’s a simple app, but it already comes with the OpenApi (Swagger) for documentation. Once you type the address:

https://localhost:5001/swagger

You will get the Swagger documentation and will be able to test the service:

But there is more. Microsoft introduced also HttpRepl, a REPL (Read-Eval-Print Loop) for testing REST services. It will scan your service and allow you to test the service using simple commands, like the file commands.

To test this new feature, in  new folder create a webapi app with

dotnet new webapi

Then, open Visual Studio Code with

code .

You will get something like that:

Then, delete the WeatherForecast.cs file and add a new folder and name it Model. In it, add a new file and name it Customer.cs and add this code:

public class Customer
{
    public string CustomerId { get; set; }
    public string CompanyName { get; set; }
    public string ContactName { get; set; }
    public string ContactTitle { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string Region { get; set; }
    public string PostalCode { get; set; }
    public string Country { get; set; }
    public string Phone { get; set; }
    public string Fax { get; set; }
}

Create a new file and name it CustomerRepository.cs and add this code:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Xml.Linq;

namespace HttpRepl.Model
{
    public class CustomerRepository
    {
        private readonly IList<Customer> customers;

        public CustomerRepository()
        {
            var doc = XDocument.Load("Customers.xml");
            customers = new ObservableCollection<Customer>((from c in doc.Descendants("Customer")
                                                            select new Customer
                                                            {
                                                                CustomerId = GetValueOrDefault(c, "CustomerID"),
                                                                CompanyName = GetValueOrDefault(c, "CompanyName"),
                                                                ContactName = GetValueOrDefault(c, "ContactName"),
                                                                ContactTitle = GetValueOrDefault(c, "ContactTitle"),
                                                                Address = GetValueOrDefault(c, "Address"),
                                                                City = GetValueOrDefault(c, "City"),
                                                                Region = GetValueOrDefault(c, "Region"),
                                                                PostalCode = GetValueOrDefault(c, "PostalCode"),
                                                                Country = GetValueOrDefault(c, "Country"),
                                                                Phone = GetValueOrDefault(c, "Phone"),
                                                                Fax = GetValueOrDefault(c, "Fax")
                                                            }).ToList());
        }

        #region ICustomerRepository Members

        public bool Add(Customer customer)
        {
            if (customers.FirstOrDefault(c => c.CustomerId == customer.CustomerId) == null)
            {
                customers.Add(customer);
                return true;
            }
            return false;
        }

        public bool Remove(Customer customer)
        {
            if (customers.IndexOf(customer) >= 0)
            {
                customers.Remove(customer);
                return true;
            }
            return false;
        }

        public bool Update(Customer customer)
        {
            var currentCustomer = GetCustomer(customer.CustomerId);
            if (currentCustomer == null)
                return false;
            currentCustomer.CustomerId = customer.CustomerId;
            currentCustomer.CompanyName = customer.CompanyName;
            currentCustomer.ContactName = customer.ContactName;
            currentCustomer.ContactTitle = customer.ContactTitle;
            currentCustomer.Address = customer.Address;
            currentCustomer.City = customer.City;
            currentCustomer.Region = customer.Region;
            currentCustomer.PostalCode = customer.PostalCode;
            currentCustomer.Country = customer.Country;
            currentCustomer.Phone = customer.Phone;
            currentCustomer.Fax = customer.Fax;
            return true;    
        }

        public bool Commit()
        {
            try
            {
                var doc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
                var root = new XElement("Customers");
                foreach (Customer customer in customers)
                {
                    root.Add(new XElement("Customer",
                                          new XElement("CustomerID", customer.CustomerId),
                                          new XElement("CompanyName", customer.CompanyName),
                                          new XElement("ContactName", customer.ContactName),
                                          new XElement("ContactTitle", customer.ContactTitle),
                                          new XElement("Address", customer.Address),
                                          new XElement("City", customer.City),
                                          new XElement("Region", customer.Region),
                                          new XElement("PostalCode", customer.PostalCode),
                                          new XElement("Country", customer.Country),
                                          new XElement("Phone", customer.Phone),
                                          new XElement("Fax", customer.Fax)
                                 ));
                }
                doc.Add(root);
                doc.Save("Customers.xml");
                return true;
            }
            catch (Exception)
            {
                return false;
            }
        }

        public IEnumerable<Customer> GetAll() => customers;

        public Customer GetCustomer(string id) => customers.FirstOrDefault(c => string.Equals(c.CustomerId, id, StringComparison.CurrentCultureIgnoreCase));

        #endregion

        private static string GetValueOrDefault(XContainer el, string propertyName)
        {
            return el.Element(propertyName) == null ? string.Empty : el.Element(propertyName).Value;
        }
    }
}

This code will use a file, named Customers.xml and will use it to serve the customers repository. With it, you will be able to get all customers, get, add, update or delete one customer. We will use it to serve our controller. The Customers.xml can be obtained at . You should add this file to the main folder and then add this code:

<ItemGroup >
  <None Update="customers.xml" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

This will ensure that the xml file is copied to the output folder when the project is built.

Then, in the Controllers folder, delete the WeatherForecastController and add a new file, CustomerController.cs and add this code:

using System.Collections.Generic;
using HttpRepl.Model;
using Microsoft.AspNetCore.Mvc;

namespace HttpRepl.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class CustomerController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<Customer> Get()
        {
            var customerRepository = new CustomerRepository();
            return customerRepository.GetAll();
        }

        [HttpGet("{id}")]
        public IActionResult GetCustomer(string id)
        {
            var customerRepository = new CustomerRepository();
            Customer customer = customerRepository.GetCustomer(id);
            return customer != null ? Ok(customer) : NotFound();
        }

        [HttpPost]
        public IActionResult Add([FromBody] Customer customer)
        {
            if (string.IsNullOrWhiteSpace(customer.CustomerId))
              return BadRequest();
            var customerRepository = new CustomerRepository();
            if (customerRepository.Add(customer))
            {
                customerRepository.Commit();
                return CreatedAtAction(nameof(Get), new { id = customer.CustomerId }, customer);
            }
            return Conflict();
        }

        [HttpPut]
        public IActionResult Update([FromBody] Customer customer)
        {
            if (string.IsNullOrWhiteSpace(customer.CustomerId))
              return BadRequest();
            var customerRepository = new CustomerRepository();
            var currentCustomer = customerRepository.GetCustomer(customer.CustomerId);
            if (currentCustomer == null)
                return NotFound();
            if (customerRepository.Update(customer))
            {
                customerRepository.Commit();
                return Ok(customer);
            }
            return NoContent();
        }

        [HttpDelete("{id}")]
        public IActionResult Delete([FromRoute]string id)
        {
            if (string.IsNullOrWhiteSpace(id))
              return BadRequest();
            var customerRepository = new CustomerRepository();
            var currentCustomer = customerRepository.GetCustomer(id);
            if (currentCustomer == null)
                return NotFound();
            if (customerRepository.Remove(currentCustomer))
            {
                customerRepository.Commit();
                return Ok();
            }
            return NoContent();
        }
    }
}

This controller will add actions to get all customers, get add, update or delete one customer. The project is ready to be run. You can run it with dotnet run and, when you open a new browser window and type this address:

https://localhost:5001/swagger

You will get something like this:

You can test the service with the Swagger page (as you can see, it was generated automatically wen you compiled and ran the app), but there is still another tool: HttpRepl. This tool was added with .NET 5 and you can install it with the command:

dotnet tool install -g Microsoft.dotnet-httprepl

Once you install it, you can run it with

httprepl https://localhost:5001

When you run it, you will get the REPL prompt:

If you type the uicommand, a new browser window will open with the Swagger page. You can type lsto list the available controllers and actions:

As you can see, it has a folder structure and you can test the service using the command line. For example, you can get all the customers with get customer or get one customer with get customer/blonp:

You can also “change directory” to the Customer “directory” with cd Customer. In this case, you can query the customer with get blonp:

If the body content is simple, you can use the -c parameter, like in:

post -c "{"customerid":"test"}"

This will add a new record with just the customer id:

If the content is more complicated, you must set the default editor, so you can edit the customer that will be inserted in the repository. You must do that with the command:

pref set editor.command.default "c:\windows\system32\notepad.exe"

This will open notepad when you type a command that needs a body, so you can type the body that will be sent when the command is executed. If you type post in the command line, notepad will open to edit the data. You can type this data:

{
  "customerId": "ABCD",
  "companyName": "Abcd Inc.",
  "contactName": "A.B.C.Dinc",
  "contactTitle": "Owner",
  "address": "1234 - Acme St - Suite A",
  "city": "Abcd",
  "region": "AC",
  "postalCode": "12345",
  "country": "USA",
  "phone": "(501) 555-1234"
}

When you save the file and close notepad, you will get something like this:

If you try to add the same record again, you will get an error 409, indicating that the record already exists in the database:

As you can see, the repository is doing the checks and sending the correct response to the REPL. You can use the same procedure to update or delete a customer. For the delete, you just have to pass the customer Id:

Now that we know all the commands we can do with the REPL, we can go one step further: using scripts. You can write a text file with the commands to use and run the script. Let’s say we want to exercise the entire API, by issuing all commands in a single run. We can create a script like this (we’ll name it TestApi.txt)

connect https://localhost:5001
ls
cd Customer
ls
get 
get alfki
post --content "{"customerId": "ABCD","companyName": "Abcd Inc.","contactName": "A.B.C.Dinc"}"
get abcd
put --content "{"customerId": "ABCD","companyName": "ACME Inc"}"
delete abcd
get abcd
cd ..
ls

And then open HttpRepl and run the command

run testapi.txt

We’ll get this output:

As you can see, with this tool, you get a very easy way to exercise your API. It’s not something fancy as a dedicated program like Postman, but it’s easy to use and does its job.

The full code for the project is at https://github.com/bsonnino/HttpRepl

 

In the last post I’ve shown how to use the new WebView2 control in a WPF app and said that it could be used in any Windows version and in any platform. As a matter of fact, I can create a 64 bit VCL native application with Delphi, that uses the Win32 API with the same control, offering the same functionality that the previous app did. To do that, you must have Delphi 10.4 Sydney installed in your machine. It offers the TEdgeBrowser component, that can be used to browse the web using the Chromium component.

In Delphi, create a new VCL application. Add the 64 bit platform to it and set it default. Add a TPanel, docked at the top. Then add a TEdgeBrowser and doc it to fill the window. In the panel, add a TLabel and change the Caption property to Search Text, add a TEdit and clear its Text property. Then, add two buttons and set their property Caption to Find and Copy. Add two more buttons at the right of the panel and set their sizes to 23×23, and their font Height to 10, Name to Segoe MDL2 Assets and their caption to the Back and Forward icons in the Character Map:

You should have something like this:

Now you can set the OnCreate event of the main form to

EdgeBrowser1.Navigate('https://docs.microsoft.com');

and run the application, but you won’t see nothing in the window. You can check the initialization of the WebViewer in the OnCreatedWebViewCompleted event and check the AResult parameter.

procedure TForm1.EdgeBrowser1CreateWebViewCompleted(Sender: TCustomEdgeBrowser;
  AResult: HRESULT);
begin
  if AResult <> 0 then
    ShowMessage('Error initializing WebView: $'+IntToHex(AResult));
end;

You will see that there is an error code (in my machine it’s $80004005). This is due to the fact that the WebView dll is missing. The dll is in the Redist folder and you have to add a PostBuild command to the project. Go to Project/Options and select Build options and, in the command “Value from All Configurations – Windows 64 bits”, add the following command:

copy /Y "C:\Program Files (x86)\Embarcadero\Studio\21.0\Redist\win64\WebView2Loader.dll" $(OUTPUTDIR)

This will copy the dll to the output directory and will initialize the WebView correctly(you will see that there is no message in the OnCreatedWebViewCompleted event and the page is loaded. Now, we can add the event handlers for the buttons. The Find button will set up the address and call the Navigate method (You must add the NetEncoding unit to the Uses clause):

procedure TForm1.FindClick(Sender: TObject);
begin
  if Edit1.Text <> '' then begin
    var address := 'https://docs.microsoft.com/en-us/search/?terms=' +
      TNetEncoding.URL.Encode(Edit1.Text);
    EdgeBrowser1.Navigate(address);
  end;
end;

The Back and Forward buttons events are very simple to implement:

procedure TForm1.BackClick(Sender: TObject);
begin
  if EdgeBrowser1.CanGoBack then
    EdgeBrowser1.GoBack;
end;

procedure TForm1.ForwardClick(Sender: TObject);
begin
  if EdgeBrowser1.CanGoForward then
    EdgeBrowser1.GoForward;
end;

To remove the parts of the page that we don’t want, we use the OnNavigationCompleted event to inject the JavaScript code:

procedure TForm1.EdgeBrowser1NavigationCompleted(Sender: TCustomEdgeBrowser;
  IsSuccess: Boolean; WebErrorStatus: TOleEnum);
begin
  if IsSuccess then
    EdgeBrowser1.ExecuteScript(
' var rss = document.querySelector(''[data-bi-name="search-rss-link"]'');'+ #13#10 +
' console.log(rss);'+ #13#10 +
' if (rss)'+ #13#10 +
'   rss.style.display = "none";'+ #13#10 +
' var form = document.getElementById("facet-search-form");'+ #13#10 +
' console.log(form);'+ #13#10 +
' if (form)'+ #13#10 +
'   form.style.display = "none";'+ #13#10 +
' var container = document.getElementById("left-container");'+ #13#10 +
' console.log(container);'+ #13#10 +
' if (container)'+ #13#10 +
'   container.style.display = "none";'+ #13#10 +
' var hiddenClasses = ["header-holder", "footerContainer"];'+ #13#10 +
' var divs = document.getElementsByTagName("div");'+ #13#10 +
' for( var i = 0; i < divs.length; i++) {'+ #13#10 +
'   if (hiddenClasses.some(r=> divs[i].classList.contains(r))){'+ #13#10 +
'     divs[i].style.display = "none";'+ #13#10 +
'   }'+ #13#10 +
' }');
end;

You can see here the same JavaScript code we used in the previous post, it also logs to the console the value of the variables. You can also open the Developer Tools of the browser component by pressing F12.

Now, there is only the Copy button to get the results and copy them to the clipboard. We will inject the code that gets all the results and send them to the application:

procedure TForm1.CopyClick(Sender: TObject);
begin
EdgeBrowser1.ExecuteScript(
'var results = [...document.querySelectorAll(''[data-bi-name="result"]'')]'+
'.map(a => {'+ #13#10 +
' let aElement = a.querySelector("a");'+ #13#10 +
' return {'+ #13#10 +
' title: aElement.innerText,'+ #13#10 +
' link: aElement.getAttribute("href")'+ #13#10 +
' };'+ #13#10 +
'});'+ #13#10 +
'console.log(results);'+ #13#10 +
'if (results.length >= 1){'+ #13#10 +
' window.chrome.webview.postMessage(results);'+ #13#10 +
'}'+ #13#10 +
'else {'+ #13#10 +
' alert("There are no results in the page");'+ #13#10 +
'}');
end;

This is the same code that is injected in the WPF app, with a slight difference: in the WPF program we’ve sent a message and added a listener in the JavaScript code. Here, we are running the code when the user clicks the button. This will send a message to the app, that will be processed in the OnMessageReceived event:

procedure TForm1.EdgeBrowser1WebMessageReceived(Sender: TCustomEdgeBrowser;
  Args: TWebMessageReceivedEventArgs);
var
  json : PWideChar;
begin
  var msg := Args as ICoreWebView2WebMessageReceivedEventArgs;
  msg.Get_webMessageAsJson(json);
  Clipboard.AsText := json;
  ShowMessage('Results sent to clipboard');
end

Now, when you run the program, you will have the same results as in the WPF program:

As you can see, with the new WebView2 component you have a lot of flexibility, you can use it in .NET or Win32 programs with almost no change. You can use this component to browse the Web and get data from the browsing, or you can use it to complement your current app: let’s say you have parts of your app that are already written for the web and you don’t want to rewrite them, but use them to interact with your app, you can add these parts and include them in your desktop app.

The full source code for this project is at https://github.com/bsonnino/WebViewDelphi

 

 

Times have changed and Microsoft is not the same: Edge, the new Microsoft browser has been remodeled and now it’s using the Chromium engine, an open source browser engine developed by Google.

With that, it has also changed the way you can develop browser apps – you will be able to use the same browser engine Microsoft uses in its browser to develop your browser apps. In order to do that, you will have to use the new WebView control. The new WebView control is not tied to a specific Windows version or development platform: You can use it in any Windows version, from 7 to 10 or use it in a Win32, .NET (core or full framework) or UWP app.

This article will show how to use it and interact with it in a WPF app. We will develop a program that will search in the Microsoft docs site, so you will be able to easily search there for information.

Introduction

Using the new WebView2 control in a .NET app is very simple, it’s just a matter of adding a NuGet package and you’re already setup. In Visual Studio, create a new WPF app. Right click the dependencies node in the Solution Explorer and select “Manage NuGet Packages” , the select the Microsoft.Web.WebView2 package. Then, in MainWindow.xaml, add the main UI:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="40"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <StackPanel Orientation="Horizontal">
        <TextBlock Text="Search Text" Margin="5" VerticalAlignment="Center"/>
        <TextBox Text="" x:Name="SearchText" Margin="5" VerticalAlignment="Center"
                 Width="400" Height="30" VerticalContentAlignment="Center"/>
        <Button Content="Find" Width="65" Height="30" Margin="5" Click="ButtonBase_OnClick"/>
    </StackPanel>
    <wpf:WebView2 x:Name="WebView" Grid.Row="1" Source="" />
</Grid>

You will have to add the wpf  namespace to the xaml:

xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"

As you can see, we are adding a textbox for the text to search and a button to activate the search. In the WebView control, we left the Source property blank, as we don’t want to go to a specific site. If we wanted to start with some page, we would have to fill this property. For example, if you fill the Source property with  https://docs.microsoft.com you would have something like this:

 

Navigating with the WebView

As you can see, it’s very easy to add a browser to your app, but we want to add more than a simple browsing experience. We want to make our app a custom way of browsing. To do that, we will use the following button click event handler:

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    if (string.IsNullOrWhiteSpace(SearchText.Text))
        return;
    var searchAddress =
        $"https://docs.microsoft.com/en-us/search/?terms={HttpUtility.UrlEncode(SearchText.Text)}";
    WebView?.CoreWebView2?.Navigate(searchAddress);
}

We’ll take the text the user will want to search, create an URL for searching in the Microsoft docs website and then navigate to it. Once we do that, we can search the docs, just by typing the wanted text and clicking the button:

We can also add back and forward navigation by adding these two buttons:

<StackPanel Orientation="Horizontal" Grid.Row="0" HorizontalAlignment="Right" TextElement.FontFamily="Segoe MDL2 Assets">
    <Button Content="&amp;#xE0A6;" Width="30" Height="30" Margin="5" ToolTip="Back" Click="GoBackClick"/>
    <Button Content="&amp;#xE0AB;" Width="30" Height="30" Margin="5" ToolTip="Forward" Click="GoForwardClick"/>
</StackPanel>

The click event handler that will allow the browser to navigate to the previous or next page in history is:

private void GoBackClick(object sender, RoutedEventArgs e)
{
    if (WebView.CanGoBack)
        WebView.GoBack();
}

private void GoForwardClick(object sender, RoutedEventArgs e)
{
    if (WebView.CanGoForward)
        WebView.GoForward();
}

Once you have this code in place, you are able to use the two buttons to navigate in the browser history.

Customizing the page

One thing that bothers me in this app is the fact that we have several items in the page that don’t belong to our search: the top bar, the footer bar, the search box, and so on. Wouldn’t it be nice to clean these items from the page when we are showing it? Well, there is a way to do that, but we’ll have to resort to JavaScript to do that: when the page is loaded, we’ll inject a JavaScript script in the page that will remove the parts we don’t want. For that, we must create this code:

        async void InitializeAsync()
        {
            await WebView.EnsureCoreWebView2Async(null);
            WebView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded;
        }

        private async void CoreWebView2_DOMContentLoaded(object sender, CoreWebView2DOMContentLoadedEventArgs e)
        {

            await WebView.ExecuteScriptAsync(
                @"
window.onload = () => {
  var rss = document.querySelector('[data-bi-name=""search-rss-link""]');
  console.log(rss);
  if (rss)
    rss.style.display = 'none'; 
  var form = document.getElementById('facet-search-form');
  console.log(form);
  if (form)
    form.style.display = 'none';  
  var container = document.getElementById('left-container');
  console.log(container);
  if (container)
    container.style.display = 'none';  
  var hiddenClasses = ['header-holder', 'footerContainer'];
  var divs = document.getElementsByTagName('div');
  for( var i = 0; i < divs.length; i++) {
    if (hiddenClasses.some(r=> divs[i].classList.contains(r))){
      divs[i].style.display = 'none';
    }
  }
}");
        }

We have two parts in this code: initially, we ensure that the CoreWebView2 component is created and then we set a DomContentLoaded event handler, that will be called when the HTML content is loaded in the browser. In the event handler, we will inject the script and execute it with ExecuteScriptAsync. That is enough to remove the parts we don’t want from the page. The JavaScript code will retrieve the parts we want to hide and set their display style to none. This code is called from the constructor of the main window:

public MainWindow()
{
    InitializeComponent();
    InitializeAsync();
}

 

You can also see the console.log commands in the code. You can debug the JavaScript code when browsing by using the F12 key. The developer window will open in a separate window:

As you can see from the image, the top bar was removed, we now have a cleaner page. You can use the same technique to remove the context menu or add other functionality to the browser. Now we will get some extra info from the page.

Communicating between the app and the browser

When we are browsing the results page, we can get something from it. We can copy the results to the clipboard, so we can use them later. To do that we must use the communication between the app and the WebView. This is done by a messaging process. The app can send a message to the WebView, using something like

WebView?.CoreWebView2?.PostWebMessageAsString("message");

The WebView will receive the message and can process it by adding an event listener like in

window.chrome.webview.addEventListener('message', event => {
  if (event.data === 'message') {
    // process message
  }
});

When we want to send messages in the other direction, from the WebView to the app, we can send it from JavaScript, using

window.chrome.webview.postMessage(message);

It will be received by the app with an event handler like

WebView.CoreWebView2.WebMessageReceived += (s, args) =>
{
  data = args.WebMessageAsJson;
  // Process data
}

That way, we can have full communication between the app and the WebView and we can add the functionality we want: copy the results from the results page to the clipboard. The first step is to add the button to copy the results in MainWindow.xaml:

<StackPanel Orientation="Horizontal">
    <TextBlock Text="Search Text" Margin="5" VerticalAlignment="Center"/>
    <TextBox Text="" x:Name="SearchText" Margin="5" VerticalAlignment="Center"
             Width="400" Height="30" VerticalContentAlignment="Center"/>
    <Button Content="Find" Width="65" Height="30" Margin="5" Click="ButtonBase_OnClick"/>
    <Button Content="Copy" Width="65" Height="30" Margin="5" Click="CopyClick"/>
</StackPanel>

The click event handler will send a message to the WebView:

private void CopyClick(object sender, RoutedEventArgs e)
{
    WebView?.CoreWebView2?.PostWebMessageAsString("copy");
}

We must inject some code in the web page to receive and process the message, this is done by using the AddScriptToExecuteOnDocumentCreatedAsync method to inject the code when the page is loaded, in the InitializeAsync method:

        async void InitializeAsync()
        {

            await WebView.EnsureCoreWebView2Async(null);
            WebView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded;
            await WebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@"
window.chrome.webview.addEventListener('message', event => {
  if (event.data === 'copy') {
    var results = [...document.querySelectorAll('[data-bi-name=""result""]')].map(a => {
            let aElement = a.querySelector('a');
            return {
                title: aElement.innerText,
                link: aElement.getAttribute('href')
            };
        });
    if (results.length >= 1){
      window.chrome.webview.postMessage(results);
    }
    else {
      alert('There are no results in the page');
    }
  }
});");
            WebView.CoreWebView2.WebMessageReceived += DataReceived;
        }

The JavaScript code will add the event listener, that will get all results in the page using the querySelectorAll  method, and then it will map it to an array of objects that have the title and link of the result, then will send this array to the app with postMessage. In the case that there are no results in the page, an alert message is shown. The code also sets the event handler for the WebMessageReceived event:

void DataReceived(object sender, CoreWebView2WebMessageReceivedEventArgs args)
{
    var data = args.WebMessageAsJson;
    Clipboard.SetText(data);
    MessageBox.Show("Results copied to the clipboard");
}

The handler will get the sent data with the args.WebMessageAsJson, then it will send it to the clipboard as text, where it can be copied to any program. Now, when you run the program, do a search and click the Copy  button, you will have something like this in the clipboard:

[
    {
        "title": "Getting started with WebView2 for WinForms apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/gettingstarted/winforms"
    },
    {
        "title": "Microsoft Edge WebView2 Control - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/"
    },
    {
        "title": "Getting started with WebView2 for WinUI apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/gettingstarted/winui"
    },
    {
        "title": "Getting started with WebView2 for WPF apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/gettingstarted/wpf"
    },
    {
        "title": "Versioning of Microsoft Edge WebView2 - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/versioning"
    },
    {
        "title": "Distribution of Microsoft Edge WebView2 apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution"
    },
    {
        "title": "Getting started with WebView2 for Win32 apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/gettingstarted/win32"
    },
    {
        "title": "Release Notes for Microsoft Edge WebView2 for Win32, WPF, and WinForms - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/releasenotes"
    },
    {
        "title": "Use JavaScript in WebView2 apps - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/howto/js"
    },
    {
        "title": "Microsoft Edge WebView2 API Reference - Microsoft Edge Development",
        "link": "https://docs.microsoft.com/en-us/microsoft-edge/webview2/webview2-api-reference"
    }
]

Now we have an app that can browse to a page and interact with it.

Conclusion

There are many uses to this feature and the new Chromium WebView is a welcome addition to our toolbox, we can interact with the web page, retrieving and sending data to it.

The full source code for this article is at https://github.com/bsonnino/WebViewWpf