Ranting about MAUI and Pitfall Knowledge Points

Ranting about MAUI and Pitfall Knowledge Points

Here is a summary of the pitfalls the author encountered while developing MAUI applications, along with some notes.

Last updated 1/18/2023 10:02 PM
痴者工良
33 min read
Category
MAUI
Tags
.NET MAUI

This is a collection of pitfalls I encountered while developing MAUI applications, along with some notes.

I have to say MAUI is quite garbage.

If it weren't for Mono's shining example, probably not many in the community would pay attention to the tarnished MAUI.

Currently, .NET has been upgraded to 7.0, but MAUI remains as lackluster as ever. If you have experience developing with MAUI, doing customizations like custom title bars, you'll see how painful MAUI can be.

I don't know what MAUI has to do with UWP, but many things in MAUI seem to continue UWP's design, and MAUI is likely to be the next UWP.

For Windows or Linux desktop development, I recommend WPF or Avalonia. MAUI really can't hold its own; under WPF you can find many APIs, but MAUI doesn't have them...

MAUI Windows is based on WINUI development, but if you search for resources according to WINUI, what you find doesn't work with MAUI.

Here I recommend a good front-end packaging client tool: tauri. Tauri is implemented in Rust and can be combined with other front-end frameworks to develop applications.

Its development model is much better than MAUI Blazor, the development experience is also very good, the generated software size is surprisingly small, and the generated app comes with its own installation interface. The program is already packaged, saving you the trouble of manually repackaging.

Article introduction: https://www.whuanle.cn/archives/21062

Compared to other desktop development, MAUI is really bloated, and developing with MAUI Blazor is really unpleasant, including the development experience of Visual Studio itself, the use of the framework, and the Blazor UI framework—all three aspects are not good enough.

There are few capable Blazor frameworks, and even fewer UI frameworks that are easily extendable and modifiable. Among the available Blazor frameworks, MASA Blazor is relatively good.

Why do I say this? First, during Blazor coding, the editor's support for Razor is poor: often no syntax hints, code errors without editor warnings, editor warnings of errors that aren't actually errors, and so on.

Second, regarding the use of Blazor under MAUI and the selection of Blazor frameworks. When using Blazor in MAUI, if you use a third-party UI framework, after introducing it, you'll find it has a natural kind of closed nature.

If you use a pure front-end framework, you'll find the dependency references are clear: what packages need to be referenced, the compiler will prompt, and it will prompt during compilation.

But with Blazor frameworks, it's hard to know which JS files are used internally; the Blazor DLL contains embedded JS files and the like—itself a kind of closed nature—and understanding the internal situation is even more difficult. Debugging bugs is hard.

Moreover, the code encapsulated by Blazor frameworks is written in C# + JS. Since C# code cannot be modified after compilation, when a referenced Blazor library has issues, it's hard to view and debug the source code. Also, from current Blazor frameworks, I've seen many frameworks where the code itself is very bloated, the design and logic are unclear, and many parts of the code limit component extensibility, making it difficult for developers to replace internal implementations.

Over-design is also a problem: supporting for the sake of support, flexibility for the sake of flexibility, too many API designs and parameter presentations, too much logic encapsulation actually makes these components harder to use.

Most Blazor frameworks are maintained by individual repositories. In contrast, high-quality front-end frameworks on the market are almost always backed by large companies—like ViewJS, Ant Design—their frameworks are maintained by professional teams and designed by experts, jointly maintaining a high-quality product.

But for the current Blazor, I think except what MASA has done, it's hard to call anything else "high-quality."

To praise MASA, I have my reasons.

MASA is truly dedicated to building an ecosystem, attracting many developers and fans to actively participate. Their open-source sharing spirit is admirable.

If you have problems with Blazor or MAUI development, even if you're not using the MASA framework, you can ask questions in the MASA community. No paid Q&A, no one will laugh at you for being a rookie or not understanding something. Of course, I'm not saying that paid Q&A for open-source projects is wrong; I'm just praising MASA's open-source spirit.

Official website: https://www.masastack.com/blazor

I look forward to the MASA team producing a high-quality product.

But currently, MAUI + Blazor desktop development has no advantages... and it brings many problems...

If possible, I don't want to touch MAUI again.

Below, I'll introduce some knowledge points about MAUI.

Window

First, after creating a project, in APP.cs there is a Microsoft.Maui.Controls.Window.

In MauiProgram.cs, there is a Microsoft.UI.Xaml.Window, and under Windows, Microsoft.UI.Xaml.Window is Microsoft.Maui.MauiWinUIWindow. Microsoft.UI.Xaml.Window is a unified abstraction across multiple platforms.

Then Microsoft.UI.Xaml.Window can obtain an AppWindow.

AppWindow appWindow = nativeWindow.GetAppWindow()!;

The Window class API in MAUI is very chaotic. Most are inherited from UWP syntax, and many APIs that UWP has are missing in MAUI.

Chaotic.

If you write a page yourself and want to pop up this window page, you should use Microsoft.Maui.Controls.Window, but the page you wrote is a ContentPage, not a Window.

Therefore, you can't directly use Window; you need to put the ContentPage into a Window, generate the Window, and then operate on it.

private Microsoft.Maui.Controls.Window BuildUpdateWindow(ContentPage updatePage)
{
    Window window = new Window(updatePage);
    window.Title = "Update Notification";
    return window;
}

Then pop up this window.

Application.Current!.OpenWindow(updateWindow!);

If you want to open the window asynchronously, use Application.Current!.Dispatcher.DispatchAsync.

await Application.Current!.Dispatcher.DispatchAsync(async () =>
{
    try
    {
        Application.Current!.OpenWindow(updateWindow!);
    }
    catch (Exception ex)
    {
        Logger.LogError("Unable to start update window", ex);
    }
});

If you want to close all windows:

await Application.Current!.Dispatcher.DispatchAsync(async () =>
{
    var windows = Application.Current!.Windows.ToArray();
    foreach (var window in windows)
    {
        try
        {
            Application.Current.CloseWindow(window);
        }
        catch (Exception ex)
        {
            Debug.Assert(ex != null);
        }
    }
});

Although you obtain Microsoft.Maui.Controls.Window, you cannot directly manage this Window; instead, you should manage it through Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow.

That is, write it in the window lifecycle management within dependency injection.

Or unless you can get the AppWindow instance.

Unfortunately, Microsoft.Maui.Controls.Window cannot be converted to Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow.

You should write it like this:

builder.ConfigureLifecycleEvents(events =>
{
    events.AddWindows(wndLifeCycleBuilder =>
    {
        wndLifeCycleBuilder.OnWindowCreated(window =>
        {
            var nativeWindow = (window as Microsoft.Maui.MauiWinUIWindow)!;
            ... ...
        })
        .OnActivated((window, args) =>
        {
        })
        .OnClosed((window, args) =>
        {
        });
    });
});
private static void MainWindowCreated(MauiWinUIWindow nativeWindow)
{
    const int width = 1440;
    const int height = 900;

    AppWindow appWindow = nativeWindow.GetAppWindow()!;

    // Extend title bar; to customize title bar color, must be true
    nativeWindow.ExtendsContentIntoTitleBar = true;

    // Must be set to Overlapped, then the window Presenter becomes OverlappedPresenter for easier control
    appWindow.SetPresenter(AppWindowPresenterKind.Overlapped);

    //if (appWindow.Presenter is OverlappedPresenter p)
    //{
    //   // p.SetBorderAndTitleBar(hasBorder: false, hasTitleBar: true);
    //}

    // Reset default open size
    appWindow.MoveAndResize(new RectInt32(1920 / 2 - width / 2, 1080 / 2 - height / 2, width, height));

    // Various window resize events
    appWindow.Changed += (w, e) =>
    {
        // Position changed
        if (e.DidPositionChange)
        {
        }
        if (e.DidPresenterChange) { }
        // Size changed
        if (e.DidSizeChange) { }
        if (e.DidVisibilityChange) { }
        if (e.DidZOrderChange) { }
    };
    appWindow.Closing += async (w, e) =>
    {
        try
        {
            Environment.Exit(0);
        }
        catch (Exception ex)
        {
            var log = AppHelpers.LoggerFactory.CreateLogger<AppWindow>();
            log.LogError(ex, "Can't close WebHost");
            ProcessManager.ReleaseLock();
        }
        finally
        {
            ProcessManager.ExitProcess(0);
        }
    };
    appWindow.MoveInZOrderAtTop();
}

Second, it's also troublesome to directly get the current window instance.

You can get all currently open windows of the program with the following code.

App.Current.Windows
Application.Current.Windows

If you want to get the currently active or focused window, I don't know how to do it through the API... Using Win32, yes.

Question: Is there an API like this?

Current.GetWindos()

Also, MAUI cannot customize the title bar—even if the king of heaven comes, it's impossible.

You'll probably be exhausted just trying to change the background color of the title bar.

If you want to modify the window title, you can only do it when the window is created, i.e., with Microsoft.Maui.Controls.Windows. Using Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow won't work.

Microsoft.Maui.Controls.Window window = base.CreateWindow(activationState);
window.Title = Constants.Name;

If you want to get the native Window handle, you can use:

var nativeWindow = mauiWindow.Handler.PlatformView;
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(nativeWindow);

Window Management

As mentioned earlier, to manage windows, use the APIs from Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow.

In some places, you have to use the native Window handle and then operate with Win32.

When customizing the window lifecycle, you must use:

// Must be set to Overlapped; then the window Presenter becomes OverlappedPresenter for easier control
appWindow.SetPresenter(AppWindowPresenterKind.Overlapped);

Common window methods:

/*
    AppWindow's Presenter must be OverlappedPresenter
    */
public class WindowService : IWindowService
{
    private readonly AppWindow _appWindow;
    private readonly Window _window;

    private WindowService(AppWindow appWindow, Window window)
    {
        _appWindow = appWindow;
        _window = window;
    }

    // Check if the current window is full screen
    public bool FullScreenState
    {
        get
        {
            switch (_appWindow.Presenter)
            {
                case OverlappedPresenter p: return p.State == OverlappedPresenterState.Maximized;
                case FullScreenPresenter p: return p.Kind == AppWindowPresenterKind.FullScreen;
                case CompactOverlayPresenter p: return p.Kind == AppWindowPresenterKind.FullScreen;
                case AppWindowPresenter p: return p.Kind == AppWindowPresenterKind.FullScreen;
                default: return false;
            }
        }
    }

    // Make the window full screen
    public void FullScreen()
    {
        switch (_appWindow.Presenter)
        {
            case OverlappedPresenter overlappedPresenter:
                overlappedPresenter.SetBorderAndTitleBar(true, true);
                overlappedPresenter.Maximize();
                break;
        }
        // Full screen without taskbar
        // _appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
    }

    // Exit full screen
    public void ExitFullScreen()
    {
        switch (_appWindow.Presenter)
        {
            case OverlappedPresenter p: p.Restore(); break;
            default: _appWindow.SetPresenter(AppWindowPresenterKind.Default); break;
        }
    }

    // Minimize to taskbar
    public void Minmize()
    {
#if WINDOWS
        var mauiWindow = App.Current.Windows.First();
        var nativeWindow = mauiWindow.Handler.PlatformView;
        IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(nativeWindow);

        PInvoke.User32.ShowWindow(windowHandle, PInvoke.User32.WindowShowStyle.SW_MINIMIZE);
#endif
    }

    /// <summary>
    /// Activate the current window
    /// </summary>
    public void Active()
    {
        _appWindow.Show(true);
    }

    // Close the window
    public void Exit()
    {
        _window.Close();
    }

    public void SetSize(int _X, int _Y, int _Width, int _Height)
    {
        _appWindow.MoveAndResize(new RectInt32(_X, _Y, _Width, _Height));
    }

    public (int X, int Y) GetPosition()
    {
        var p = _appWindow.Position;
        return (p.X, p.Y);
    }

    public (int X, int Y) Move(int x, int y)
    {
        _appWindow.Move(new PointInt32(x, y));
        return GetPosition();
    }

    public (int Width, int Height, int ClientWidth, int ClientHeight) GetSize()
    {
        var size = _appWindow.Size;
        var clientSize = _appWindow.ClientSize;
        return (size.Width, size.Height, clientSize.Width, clientSize.Height);
    }

    public (PointInt32 Position, SizeInt32 Size, SizeInt32 ClientSize) GetAppSize()
    {
        return (_appWindow.Position, _appWindow.Size, _appWindow.ClientSize);
    }
}

There are two ways to make the window full screen: one is to have the window swallow the taskbar (truly full screen), the other is to keep the taskbar.

// Keep taskbar
switch (_appWindow.Presenter)
{
    case OverlappedPresenter overlappedPresenter:
        overlappedPresenter.SetBorderAndTitleBar(true, true);
        overlappedPresenter.Maximize();
        break;
}
// Full screen without taskbar
// _appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);

Minimization can only be handled via Win32 API. You need to first get Microsoft.Maui.Controls.Windows, then convert to a Window handle.

var nativeWindow = mauiWindow.Handler.PlatformView;
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(nativeWindow);

PInvoke.User32.ShowWindow(windowHandle, PInvoke.User32.WindowShowStyle.SW_MINIMIZE);

At this point, Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow are not useful.

As mentioned earlier, to use Microsoft.UI.Xaml.Window or Microsoft.UI.Windowing.AppWindow, for example, in MauiProgram.cs to record window events and control size when creating the window.

However, during runtime, to set or limit window size, you need to go through Microsoft.Maui.Controls.Windows.

For example, to prevent the main window from being too small or infinitely shrinkable, you write this in APP.cs:

protected override Window CreateWindow(IActivationState? activationState)
{
    Window window = base.CreateWindow(activationState);
    window.Title = "Window Title";

    var minSize = GetMinSize();
    window.MinimumWidth = minSize.MinWidth;
    window.MinimumHeight = minSize.MinHeight;

    // Give the Window time to resize
    window.SizeChanged += (sender, e) =>
    {
        var minSize = GetMinSize();
        window.MinimumWidth = minSize.MinWidth;
        window.MinimumHeight = minSize.MinHeight;
    };

    //window.Created += (s, e) =>
    //{
    //};

    //window.Stopped += (s, e) =>
    //{
    //};

    return window;

    (int MinWidth, int MinHeight) GetMinSize()
    {
        // Get current screen dimensions using X, Y.
        var x = PInvoke.User32.GetSystemMetrics(PInvoke.User32.SystemMetric.SM_CXFULLSCREEN);
        var y = PInvoke.User32.GetSystemMetrics(PInvoke.User32.SystemMetric.SM_CYFULLSCREEN);
        // Set minimum window size; can calculate proportionally or set a fixed size
        return (x / 3 * 2, y / 5 * 4);
    }
}

PS: It is not possible to do size restrictions within AppWindow events. This is mainly for observation; it's not possible to implement size restrictions that way.

// Various window resize events
appWindow.Changed += (w, e) =>
{
    // Position changed
    if (e.DidPositionChange)
    {

    }
    if (e.DidPresenterChange) { }
    // Size changed
    if (e.DidSizeChange) { }
    if (e.DidVisibilityChange) { }
    if (e.DidZOrderChange) { }
};

How to Restrict Only One Instance of a Program

Scenario: If program D is already running as process A, then starting program D again as process B, B should recognize that there is an existing process and activate A's window, then B exits.

This not only restricts to a single process but also improves user experience.

A Mutex can be used for the lock; across the entire operating system, everyone can recognize the same lock.

Then, to activate the other window, Win32 can be used.

// Process manager
internal static class ProcessManager
{
    private static Mutex ProcessLock;
    private static bool HasLock;

    /// <summary>
    /// Acquire process lock
    /// </summary>
    public static void GetProcessLock()
    {
        // Global lock
        ProcessLock = new Mutex(false, "Global\\" + "Custom lock name", out HasLock);

        if (!HasLock)
        {
            ActiveWindow();
            Environment.Exit(0);
        }
    }

    /// <summary>
    /// Activate the current process and bring its window to the front
    /// </summary>
    public static void ActiveWindow()
    {
        string pName = Constants.Name;
        Process[] temp = Process.GetProcessesByName(pName);
        if (temp.Length > 0)
        {
            IntPtr handle = temp[0].MainWindowHandle;
            SwitchToThisWindow(handle, true);
        }
    }

    /// <summary>
    /// Release the current process lock
    /// </summary>
    /// <remarks>Use with caution</remarks>
    public static void ReleaseLock()
    {
        if (ProcessLock != null && HasLock)
        {
            ProcessLock.Dispose();
            HasLock = false;
        }
    }

    // Bring another window to the front
    // Win32 API
    [DllImport("user32.dll")]
    public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);
}

Then use it at program startup.

MAUI Program Installation Modes

If using the native MAUI mode directly, installation is particularly troublesome because this method is the old UWP.

Therefore, you can use the mode that runs directly without installation.

But here we need to understand the differences between the two modes.

If using native MAUI mode, the program will be generated as a Windows Store app, which is very troublesome for publishing, uploading, and installing. However, you can use many Windows app APIs.

For example, to get the application installation directory:

ApplicationData appdata = Windows.Storage.ApplicationData.Current

Getting local storage and temp directories:

var localPath = Windows.Storage.ApplicationData.Current.LocalFolder.Path
var tempPath = Windows.Storage.ApplicationData.Current.TemporaryFolder.Path

The general installation path:

"C:\\Users\\{username}\\AppData\\Local\\Packages\\{Program GUID}

Other APIs like language handling are quite convenient when using store app mode.

Next, let's talk about the custom packaging mode, which directly compiles a bunch of files and then you can start the exe to run without installation. If you want to create an installer, you can publish first and then use packaging tools to package.

Just add these two lines to the project file:

<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfConatined>true</WindowsAppSDKSelfConatined>

After that, it will directly generate an executable file, no installation package needed. The generated program runs directly, no installation or certificate required.

Setting Language for MAUI Blazor

MAUI Blazor uses WebView2 on Windows. The runtime environment of MAUI Blazor is unrelated to the program. Even if the system language is set to Chinese, the assembly language set to Chinese, local culture set to Chinese, CultureInfo set to Chinese, none of it works.

You can press F12 after the program starts and execute JavaScript code to check the browser's runtime language:

navigator.language
'en-US'

Or use the API:

// using Windows.Globalization
var langs = ApplicationLanguages.Languages.ToList<string>();

Pitfall ①

First, set Windows.Globalization:

ApplicationLanguages.PrimaryLanguageOverride = "zh-CN";

Then restart the program, and you'll see:

But the browser language still hasn't changed:

The reason is that the Preferences file needs to be regenerated for it to take effect; more on this later.

Pitfall ②

After the program starts, some WebView2 files are generated under {Windows program data directory}/{your program ID}/LocalState\EBWebView\Default, including the Preferences file, which configures WebView2 parameters.

Find your program data directory:

var path = Windows.Storage.ApplicationData.Current.LocalFolder.Path;

Therefore, you can manually modify the file to make WebView2 use the Chinese environment.

var langs = ApplicationLanguages.Languages.ToList<string>();
var cultureInfo = CultureInfo.InstalledUICulture;
var index = langs.FindIndex((lang) => cultureInfo.Equals(CultureInfo.CreateSpecificCulture(lang)));
if (index > 0)
{
	ApplicationLanguages.PrimaryLanguageOverride = cultureInfo.Name;
	// "...this is immediately reflected in the ApplicationLanguages.Languages property."
	langs = ApplicationLanguages.Languages.ToList<string>();
}
var selectedLangs = string.Join(",", langs);
// Should check if this is the same as before but...
var preferences = Windows.Storage.ApplicationData.Current.LocalFolder.Path + "\\EBWebView\\Default\\Preferences";
if (File.Exists(preferences))
{
	var jsonString = File.ReadAllText(preferences);
	var jsonObject = JObject.Parse(jsonString);    // using Newtonsoft.JSON
	//var intl = jsonObject["intl"];
	jsonObject["intl"] = JObject.Parse($@"{{""selected_languages"": ""{selectedLangs}"",""accept_languages"": ""{selectedLangs}""}}");
	jsonString = JsonConvert.SerializeObject(jsonObject);
        File.WriteAllText(preferences, jsonString);
}

Pitfall ③

Finally, I found that the idea in ① is correct; the reason it didn't work is that the Preferences file needs to be deleted and recreated. As long as you set Chinese at program startup (before WebView starts), it works.

Check the code:

public static class MauiProgram
{
    private static void SetWebViewLanguage()
    {
        ApplicationLanguages.PrimaryLanguageOverride = "zh-CN";

        var basePath = Windows.Storage.ApplicationData.Current.LocalFolder.Path;
        var preferencesFile = Path.Combine(basePath, "EBWebView/Default/Preferences"); // Preferences
        if (!File.Exists(preferencesFile)) return;

        var jsonString = File.ReadAllText(preferencesFile);
        var jsonObject = JsonObject.Parse(jsonString).AsObject();
        var languages = jsonObject["intl"]["selected_languages"].Deserialize<string>() ?? "";
        // "zh-CN,en,en-GB,en-US"
        if (!languages.StartsWith("zh-CN"))
        {
            // File.Delete(preferencesFile);
            jsonObject.Remove("intl");
            jsonObject.Add("intl", JsonObject.Parse("{\"selected_languages\":\"zh-CN,en,en-GB,en-US\"}"));
            jsonString = JsonSerializer.Serialize(jsonObject);
            File.WriteAllText(preferencesFile, jsonString);
        }
    }
// Omitted many lines of code
}

Use it in public static MauiApp CreateMauiApp():

Configuring MAUI Project to Start with Administrator Privileges

Problem Background

In Windows, applications can use an app.manifest asset file to configure the program to start with a certain role privilege.

The effect is as follows:

Normally, adding the following configuration in app.manifest works:

If this file doesn't exist in the project, you can add a new item - Manifest file.

<trustInfo xmlns='urn:schemas-microsoft-com:asm.v2'>
<security>
    <requestedPrivileges xmlns='urn:schemas-microsoft-com:asm.v3'>
    <requestedExecutionLevel level='requireAdministrator' uiAccess='false' />
    </requestedPrivileges>
</security>
</trustInfo>

But in MAUI applications, you cannot add it; if you do, an error occurs.

Platforms\Windows\app.manifest : manifest authoring error c1010001: Values of attribute "level" not equal in different manifest snippets.

Because the .NET compiler already generates an app.manifest file by default, which already contains the trustInfo configuration.

If the project has <WindowsAppSDKSelfConatined>true</WindowsAppSDKSelfConatined>, then you should check the Microsoft.WindowsAppSDK.SelfContained.targets file:

So to customize app.manifest, you either modify Microsoft.WindowsAppSDK.SelfContained.targets, but that's not ideal.

Customizing the Build Process

If you observe the build process, you'll see that the manifest file is generated into the obj directory.

The mergeapp.manifest is the app.manifest from your project; the .NET compiler renames the developer's file.

During compilation, first, the default app.manifest is generated from Microsoft.WindowsAppSDK.SelfContained.targets.

Then, the developer's app.manifest is copied as mergeapp.manifest, and mergeapp.manifest is merged into app.manifest.

If app.manifest already has the configuration, duplicate records in mergeapp.manifest will cause a compilation error.

Now that we understand the build process, we can tamper with it during compilation.

We can replace the configuration in app.manifest after it's generated but before the main program compilation. The replacement command is:

powershell -Command ";(gc app.manifest) -replace 'level=''asInvoker''', 'level=''requireAdministrator''' | Out-File -encoding ASCII app.manifest";

The steps used by MSBuild can be referenced from the official documentation: https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-targets?view=vs-2022

During the build, there are two important environment variables:

  • _DeploymentManifestFiles: directory containing manifest files
  • ApplicationManifest: path to app.manifest

You can add the following script in the .csproj file to automatically modify the manifest during compilation.

<Target Name="RequireAdministrator" BeforeTargets="GenerateManifests" Condition="'$(PublishDir)' != ''">
    <Exec WorkingDirectory="./" Command="echo $(ApplicationManifest)" />
    <Exec WorkingDirectory="./" Command="echo $(_DeploymentManifestFiles)" />
    <Exec WorkingDirectory="$(_DeploymentManifestFiles)" Command="dir" />
    <Exec WorkingDirectory="$(_DeploymentManifestFiles)" Command="powershell -Command "(gc app.manifest) -replace 'level=''asInvoker''', 'level=''requireAdministrator''' | Out-File -encoding ASCII app.manifest"" />
</Target>

BeforeTargets="GenerateManifests" indicates that the custom command runs before GenerateManifests.

Condition="'$(PublishDir)' != ''" sets the trigger condition; in MAUI, this variable only exists during publish. You could also change it to Condition="'$(Release)' != ''".

Note that sometimes the _DeploymentManifestFiles directory may not exist, so test a few times.

Of course, the safest method:

<Target Name="RequireAdministrator" BeforeTargets="GenerateManifests" Condition="'$(PublishDir)' != ''">
    <Exec WorkingDirectory="./" Command=" powershell -Command "(gc $(ApplicationManifest)) -replace 'level=''asInvoker''', 'level=''requireAdministrator''' | Out-File -encoding UTF8 $(ApplicationManifest)"" />
</Target>

The build process mainly involves the following three steps, but only GenerateManifests can be used in .csproj.

<Target Name="GenerateManifests"
        Condition="'$(GenerateClickOnceManifests)'=='true' or '@(NativeReference)'!='' or '@(ResolvedIsolatedComModules)'!='' or '$(GenerateAppxManifest)' == 'true'"
        DependsOnTargets="$(GenerateManifestsDependsOn)"/>

===================================================
GenerateApplicationManifest
Generates a ClickOnce or native application manifest.
An application manifest specifies declarative application identity, dependency and security information.
===================================================
<Target Name="GenerateApplicationManifest"
        DependsOnTargets="
                _DeploymentComputeNativeManifestInfo;
                _DeploymentComputeClickOnceManifestInfo;
                ResolveComReferences;
                ResolveNativeReferences;
                _GenerateResolvedDeploymentManifestEntryPoint"
        Inputs="
                $(MSBuildAllProjects);
                @(AppConfigWithTargetPath);
                $(_DeploymentBaseManifest);
                @(ResolvedIsolatedComModules);
                @(_DeploymentManifestDependencies);
                @(_DeploymentResolvedManifestEntryPoint);
                @(_DeploymentManifestFiles)"
        Outputs="@(ApplicationManifest)">

Available parameters:

$(_DeploymentBaseManifest);            
@(ResolvedIsolatedComModules);
@(_DeploymentManifestDependencies);
@(_DeploymentResolvedManifestEntryPoint);
@(_DeploymentManifestFiles)
@(ApplicationManifest)

MAUI Front-End and Back-End Separation Development

Background

We initially adopted Maui + Blazor development, using a popular Blazor UI framework from the community.

But during development, Maui had many pitfalls, and the Blazor UI framework also had many pitfalls. The pages and styles written with Blazor UI couldn't pass the designers' and product managers' scrutiny.

Finally, we decided to use native front-end combined with static content placed in Maui, then package both together for release.

Front-End First

For the front-end, just follow the normal development mode; no pollution of the front-end code.

You can create a front-end project in VS and put it in the solution, or create a separate directory and place the front-end code there.

Create MAUI Blazor Project

Create a MAUI Blazor project, and the solution looks like this:

First, move the wwwroot/css/app.css file out, put it in wwwroot, then create a new app.js also placed in wwwroot.

Delete all content from app.css that is unrelated to Blazor, keeping only the following:

#blazor-error-ui {
  background: lightyellow;
  bottom: 0;
  box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
  display: none;
  left: 0;
  padding: 0.6rem 1.25rem 0.7rem 1.25rem;
  position: fixed;
  width: 100%;
  z-index: 1000;
}

#blazor-error-ui .dismiss {
  cursor: pointer;
  position: absolute;
  right: 0.75rem;
  top: 0.5rem;
}

.blazor-error-boundary {
  background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=)
      no-repeat 1rem/1.8rem, #b32121;
  padding: 1rem 1rem 1rem 3.7rem;
  color: white;
}

.blazor-error-boundary::after {
  content: "An error has occurred.";
}

.status-bar-safe-area {
  display: none;
}

@supports (-webkit-touch-callout: none) {
  .status-bar-safe-area {
    display: flex;
    position: sticky;
    top: 0;
    height: env(safe-area-inset-top);
    background-color: #f7f7f7;
    width: 100%;
    z-index: 1;
  }

  .flex-column,
  .navbar-brand {
    padding-left: env(safe-area-inset-left);
  }
}

Next, put the following code into app.js to dynamically import the CSS files generated by the front-end.

function InitializeCss(name) {
  document.getElementById("app-css").innerHTML =
    '<link rel="stylesheet" href="' + name + '">';
}

Then delete the js and css directories.

The remaining files are as shown:

Then modify index.html with the following content:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
    />
    <title>MauiApp3</title>
    <base href="/" />
    <link href="/app.css" rel="stylesheet" />
  </head>

  <body>
    <div id="app-css"></div>
    <div class="status-bar-safe-area"></div>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
      An unhandled error has occurred.
      <a href="" class="reload">Reload</a>
      <a class="dismiss">🗙</a>
    </div>
    <script src="app.js"></script>
    <script src="_framework/blazor.webview.js" autostart="false"></script>
  </body>
</html>

The added <div id="app-css"></div> is used for dynamically loading CSS files.

Other content remains basically unchanged.

Open MainLayout.razor. This is responsible for dynamically loading front-end files. Its content is as follows:

@using MauiApp3.Data
@inherits LayoutComponentBase


@code {

    #region static fields

    private static readonly string[] css;
    private static readonly string[] js;

    #endregion

    #region instance fields

    [Inject]
    private IJSRuntime JS { get; set; }

    #endregion

    static MainLayout()
    {
        var path = Windows.Application­Model.Package.Current.Installed­Location.Path;

        if (Directory.Exists(Path.Combine(path, "wwwroot", "css")))
        {
            var cssList = Directory.GetFiles(Path.Combine(path, "wwwroot", "css"))
            .Where(x => x.EndsWith(".css"))
            .Select(x => Path.GetFileName(x)).ToArray();
            css = cssList;
        }
        else css = Array.Empty<string>();

        if (Directory.Exists(Path.Combine(path, "wwwroot", "js")))
        {
            var jsList = Directory.GetFiles(Path.Combine(path, "wwwroot", "js"))
                .Where(x => x.EndsWith(".js"))
                .Select(x => Path.GetFileName(x)).ToArray();
            js = jsList;
        }
        else js = Array.Empty<string>();
    }


    protected override async Task OnInitializedAsync()
    {
        await Task.CompletedTask;
    }


    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            foreach (var item in css)
            {
                await JS.InvokeVoidAsync("InitializeCss", $"css/{item}");
            }

            foreach (var item in js)
            {
                await JS.InvokeAsync<IJSObjectReference>("import", $"/js/{item}");
            }
        }
    }
}

Then, to automatically copy the front-end generated content to Maui, you can set up a script in Maui's .csproj file, adding the following:

<Target Name="ClientBuild"   BeforeTargets="BeforeBuild">
    <Exec WorkingDirectory="../vueproject1" Command="npm install" />
    <Exec WorkingDirectory="../vueproject1" Command="npm run build" />
    <Exec WorkingDirectory="../vueproject1" Command="DEL "dist\index.html"" />
    <Exec WorkingDirectory="./" Command="RMDIR /s/q "css"" />
    <Exec WorkingDirectory="./" Command="RMDIR /s/q "js"" />
    <Exec WorkingDirectory="../vueproject1" Command="Xcopy "dist" "../MauiApp3/wwwroot"  /E/C/I/Y" />
    <Exec WorkingDirectory="./" Command="RMDIR /s/q "$(LayoutDir)""/>
</Target>

This way, when the Maui project starts, it will compile the front-end project and then copy the generated files (excluding index.html) to the wwwroot directory.

After startup:

C# Automated Certificate Generation, Local Installation, and Solving Trust Issues

Background

Because when developing Blazor, the environment is https://0.0.0.0/, MAUI Blazor can only send HTTPS requests; it cannot send HTTP requests or insecure HTTPS requests. But internal network HTTPS is insecure HTTPS. So, you have to implement a proxy service locally, then let the application access via HTTPS. To make HTTPS secure, you need to install an automated certificate.

This causes JS to be unable to send requests.

To make HTTPS secure, I implemented a process for automatically generating localhost certificates and installing them.

Writing Code

Certificate generation uses .NET's built-in libraries; no third-party packages are needed.

using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

The method for generating certificates is referenced from the https://github.com/dotnetcore/FastGitHub project.

The first step is to write a certificate generator. The code can be directly copied from here: https://github.com/dotnetcore/FastGitHub/blob/9f9cbce624310c207b01699de2a5818a742e11ca/FastGitHub.HttpServer/Certs/CertGenerator.cs

Then, define a service for managing certificate generation. The original author used .NET 7, and the current stable version is .NET 6, so many APIs are not available; modifications are needed. Original address: https://github.com/dotnetcore/FastGitHub/blob/9f9cbce624310c207b01699de2a5818a742e11ca/FastGitHub.HttpServer/Certs/CertService.cs

Define certificate location and name:

private const string CACERT_PATH = "cacert";

/// <summary>
/// Get certificate file path
/// </summary>
public string CaCerFilePath { get; } = OperatingSystem.IsLinux() ? $"{CACERT_PATH}/https.crt" : $"{CACERT_PATH}/https.cer";

/// <summary>
/// Get private key file path
/// </summary>
public string CaKeyFilePath { get; } = $"{CACERT_PATH}/https.key";

Two files are involved: client certificate and private key.

  • .key is the private key; it can be used to generate server and client certificates, so only the .key private key needs to be saved; no need to export the server certificate.
  • .csr, .cer are client certificates. On Windows, .cer format can be used. The reason for exporting the client certificate is to install the certificate once; no need to generate dynamically.

The certificate management service logic is: if the ssl directory has no certificate, generate and install; if the files already exist, load them into memory without reinstalling.

Complete code:

    /// <summary>
    /// Certificate generation service
    /// </summary>
    internal class CertService
    {

        private const string CACERT_PATH = "cacert";

        /// <summary>
        /// Get certificate file path
        /// </summary>
        public string CaCerFilePath { get; } = OperatingSystem.IsLinux() ? $"{CACERT_PATH}/https.crt" : $"{CACERT_PATH}/https.cer";

        /// <summary>
        /// Get private key file path
        /// </summary>
        public string CaKeyFilePath { get; } = $"{CACERT_PATH}/https.key";

        private X509Certificate2? caCert;


        /*
         Two files are generated locally: cer and key. The cer file is imported into Windows certificate manager.
         The key file is used to generate an X509 certificate at each startup for the web service.
         */

        /// <summary>
        /// Generate CA certificate if not exists
        /// </summary>
        public bool CreateCaCertIfNotExists()
        {
            if (!Directory.Exists(CACERT_PATH)) Directory.CreateDirectory(CACERT_PATH);
            if (File.Exists(this.CaCerFilePath) && File.Exists(this.CaKeyFilePath))
            {
                return false;
            }

            File.Delete(this.CaCerFilePath);
            File.Delete(this.CaKeyFilePath);

            var notBefore = DateTimeOffset.Now.AddDays(-1);
            var notAfter = DateTimeOffset.Now.AddYears(10);

            var subjectName = new X500DistinguishedName($"CN=Link Transport Logistics Management System");
            this.caCert = CertGenerator.CreateCACertificate(subjectName, notBefore, notAfter);

            var privateKeyPem = ExportRSAPrivateKeyPem(this.caCert.GetRSAPrivateKey());
            File.WriteAllText(this.CaKeyFilePath, new string(privateKeyPem), Encoding.ASCII);

            var certPem = ExportCertificatePem(this.caCert);
            File.WriteAllText(this.CaCerFilePath, new string(certPem), Encoding.ASCII);

            return true;
        }

        /// <summary>
        /// Get a certificate issued to the specified domain
        /// </summary>
        /// <param name="domain"></param>
        /// <returns></returns>
        public X509Certificate2 GetOrCreateServerCert(string? domain)
        {
            if (this.caCert == null)
            {
                using var rsa = RSA.Create();
                rsa.ImportFromPem(File.ReadAllText(this.CaKeyFilePath));
                this.caCert = new X509Certificate2(this.CaCerFilePath).CopyWithPrivateKey(rsa);
            }

            var key = $"{nameof(CertService)}:{domain}";
            var endCert = GetOrCreateCert();
            return endCert!;

            // Generate a 1-year certificate for the domain
            X509Certificate2 GetOrCreateCert()
            {
                var notBefore = DateTimeOffset.Now.AddDays(-1);
                var notAfter = DateTimeOffset.Now.AddYears(1);

                var extraDomains = GetExtraDomains();

                var subjectName = new X500DistinguishedName($"CN={domain}");
                var endCert = CertGenerator.CreateEndCertificate(this.caCert, subjectName, extraDomains, notBefore, notAfter);

                // Reinitialize certificate to be compatible with Windows platform's inability to use in-memory certificates
                return new X509Certificate2(endCert.Export(X509ContentType.Pfx));
            }
        }
        private static IEnumerable<string> GetExtraDomains()
        {
            yield return Environment.MachineName;
            yield return IPAddress.Loopback.ToString();
            yield return IPAddress.IPv6Loopback.ToString();
        }

        internal const string RasPrivateKey = "RSA PRIVATE KEY";

        private static string ExportRSAPrivateKeyPem(RSA rsa)
        {
            var key = rsa.ExportRSAPrivateKey();
            var chars = PemEncoding.Write(RasPrivateKey, key);
            return new string(chars);
        }

        private static string ExportCertificatePem(X509Certificate2 x509)
        {
            var chars = PemEncoding.Write(PemLabels.X509Certificate, x509.Export(X509ContentType.Cert));
            return new string(chars);
        }

        /// <summary>
        /// Install CA certificate
        /// </summary>
        /// <exception cref="Exception">Cannot install certificate</exception>
        public void Install( )
        {
            var caCertFilePath = CaCerFilePath;
            try
            {
                using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine);
                store.Open(OpenFlags.ReadWrite);

                var caCert = new X509Certificate2(caCertFilePath);
                var subjectName = caCert.Subject[3..];
                foreach (var item in store.Certificates.Find(X509FindType.FindBySubjectName, subjectName, false))
                {
                    if (item.Thumbprint != caCert.Thumbprint)
                    {
                        store.Remove(item);
                    }
                }
                if (store.Certificates.Find(X509FindType.FindByThumbprint, caCert.Thumbprint, true).Count == 0)
                {
                    store.Add(caCert);
                }
                store.Close();
            }
            catch (Exception ex)
            {
                throw new Exception($"Please manually install CA certificate {caCertFilePath} into "Put all certificates in the following store"\\"Trusted Root Certification Authorities"" + ex);
            }
        }
    }
}

Usage in ASP.NET Core

Load server certificate (generate X509 certificate at each startup) in ASP.NET Core.

var sslService = new CertService();
if(sslService.CreateCaCertIfNotExists())
{
    try
    {
        sslService.Install();
    }
    catch (Exception)
    {

    }
}

var webhost =  WebHost.CreateDefaultBuilder()
    .UseStartup<Startup>()
    .UseKestrel(serverOptions =>
    {
        serverOptions.ListenAnyIP(39999,
            listenOptions =>
            {
                var certificate = sslService.GetOrCreateServerCert("localhost");
                listenOptions.UseHttps(certificate);
            });
    })
    .Build();
await webhost.RunAsync();
Keep Exploring

Related Reading

More Articles