
ここは筆者が MAUI アプリケーション開発時に踏んだ落とし穴や、いくつかのメモをまとめたものです。
MAUI は正直言ってひどいです。
もし Mono という素晴らしい先例がなければ、コミュニティが MAUI という実態の伴わないものに注目することはなかったでしょう。
現在 .NET は 7.0 にアップグレードされていますが、MAUI は相変わらずダメダメです。MAUI で開発を経験し、カスタマイズやカスタムタイトルバーなどを行った方なら、MAUI がいかに厄介かお分かりでしょう。
MAUI が UWP とどのような関係があるのかはわかりませんが、MAUI の多くの機能は UWP の設計を引き継いでいるように感じます。そして MAUI も次の UWP になる可能性が高いです。
Windows または Linux のデスクトップ開発なら、WPF または Avalonia をお勧めします。MAUI はとても見劣りします。WPF では多くの API が見つかるのに、MAUI にはありません……
MAUI Windows は WINUI ベースで開発されていますが、WINUI の資料を探しても、MAUI で使えるものはほとんどありません。
ここで筆者がお勧めする便利なフロントエンドパッケージングクライアントツールは tauri です。tauri は rust で実装されており、他のフロントエンドフレームワークと組み合わせてアプリケーションを開発できます。
その開発モデルは MAUI Blazor よりもはるかに優れており、開発体験も非常に良く、生成されるソフトウェアのサイズは驚くほど小さく、生成されたアプリにはインストーラーが組み込まれており、自動的にパッケージ化されるため、自分で手動で再パッケージする必要がありません。
記事紹介:https://www.whuanle.cn/archives/21062
他のデスクトップ開発と比較すると、MAUI は本当に肥大化しており、特に MAUI Blazor の開発は満足できるものではありません。Visual Studio 自体の開発体験、フレームワーク自体の使い勝手、Blazor UI フレームワークの3つの側面すべてで体験が十分とは言えません。
使える Blazor フレームワークは少なく、簡単に拡張・改造できる UI フレームワークはさらに少ないです。現在利用可能な Blazor フレームワークの中で、比較的良いものとして MASA Blazor があります。
なぜそう言うかというと、まず Blazor のコーディング中、エディタの Razor サポートが不十分で、構文ヒントが出なかったり、コードにエラーがあるのにエディタが知らせなかったり、エディタがエラーを示しても実際にはエラーでなかったり……といったことが頻繁に発生します。
次に、MAUI での Blazor の使用と Blazor フレームワークの選択についてです。MAUI で Blazor を使用する場合、サードパーティの UI フレームワークを導入すると、それらは本質的に閉鎖性を持っていることに気づきます。
純粋なフロントエンドフレームワークを使用して開発する場合、依存関係の参照関係が明確で、どのパッケージを参照する必要があるかはコンパイラが教えてくれ、コンパイル時に通知されます。
しかし Blazor フレームワークでは、内部でどのような JS が使われているのか把握しづらく、Blazor の DLL 内に JS などのファイルが埋め込まれているため、それ自体が閉鎖的であり、内部の状況を把握するのはさらに難しく、バグが発生した際のデバッグが困難です。
また、Blazor フレームワークがカプセル化するコードは C# + JS で書かれています。C# コードはコンパイル後に変更できないため、参照している Blazor ライブラリに問題が生じた場合、デバッグのためにソースコードを確認することが困難です。さらに、筆者は現在の Blazor フレームワークの多くが、フレームワーク自体のコードが非常に肥大化しており、内部の設計やロジックも不明瞭で、コンポーネントの拡張を制限するようなコードが多く、開発者が内部の実装を置き換えるのが難しいと感じています。
過剰設計も問題です。サポートするためのサポート、柔軟性のための柔軟性、過剰な API 設計やパラメータの提示、過剰なロジックのカプセル化は、実際にはこれらのコンポーネントを使いにくくしています。
ほとんどの Blazor フレームワークは個人のリポジトリで管理されています。一方、市場に出回っている高品質なフロントエンドフレームワーク(ViewJS、Ant Design など)は、ほとんどが大手企業のバックアップがあります。フレームワーク自体は専門チームによって保守され、熟練者によって設計され、共同で高品質なものに育てられています。
しかし現在の Blazor については、MASA が作成したものを除けば、他に「高品質」と呼べるものはほとんどないと思います。
MASA を褒めるなら、筆者には理由があります。
MASA は本当にエコシステムに力を注いでおり、多くの開発者やファンを惹きつけて活発に参加させています。そのオープンソース共有の精神は敬意に値します。
Blazor に関して問題がある場合、MAUI 開発に関して問題がある場合、たとえ MASA フレームワークを使っていなくても、MASA コミュニティで質問することができます。有料で質問に答えることはなく、誰かが「お前は素人だ」と笑うこともなく、「そんなこともわからないのか」と笑うこともありません。もちろん、筆者はオープンソースプロジェクトが有料で質問に答えること自体に問題があると言っているのではありません。私は MASA のオープンソース精神を称賛しているのです。
公式サイト:https://www.masastack.com/blazor
MASA チームが高品質な製品を生み出すことを期待しています。
しかし現状では、MAUI + Blazor のデスクトップ開発には利点がほとんどなく……多くの問題をもたらします。
できれば、もう MAUI には手を触れたくありません。
以下で、MAUI に関するいくつかの知識ポイントを紹介します。
ウィンドウ
まず、プロジェクト作成後、APP.cs には Microsoft.Maui.Controls.Window があります。

MauiProgram.cs には Microsoft.UI.Xaml.Window があり、Windows では Microsoft.UI.Xaml.Window は Microsoft.Maui.MauiWinUIWindow です。Microsoft.UI.Xaml.Window は複数プラットフォームで統一された抽象化です。

そして Microsoft.UI.Xaml.Window から AppWindow を取得できます。
AppWindow appWindow = nativeWindow.GetAppWindow()!;
MAUI の Window クラスの API は非常に混乱しています。ほとんどの API は UWP の記法を継承しており、UWP にあって MAUI にない API も多くあります。
混乱しています。
自分でページを作成し、そのウィンドウページを表示したい場合、Microsoft.Maui.Controls.Window を使用する必要があります。しかし自分で作成したページは ContentPage であり、Window ではありません。
そのため、Window を直接使用するのではなく、ContentPage を Window に入れて、Window を生成してから操作します。
private Microsoft.Maui.Controls.Window BuildUpdateWindow(ContentPage updatePage)
{
Window window = new Window(updatePage);
window.Title = "更新通知";
return window;
}
そしてこのウィンドウを表示します。
Application.Current!.OpenWindow(updateWindow!);
非同期でウィンドウを開く場合は、Application.Current!.Dispatcher.DispatchAsync を使用してください。
await Application.Current!.Dispatcher.DispatchAsync(async () =>
{
try
{
Application.Current!.OpenWindow(updateWindow!);
}
catch (Exception ex)
{
Logger.LogError("更新ウィンドウを起動できませんでした", ex);
}
});
すべてのウィンドウを閉じる場合:
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);
}
}
});
Microsoft.Maui.Controls.Window を取得しても、その Window を直接管理することはできません。代わりに Microsoft.UI.Xaml.Window または Microsoft.UI.Windowing.AppWindow を介して管理する必要があります。
つまり、依存性注入内のウィンドウライフサイクル管理内で記述します。
または、AppWindow インスタンスを取得できる場合のみ可能です。
残念ながら、Microsoft.Maui.Controls.Window を Microsoft.UI.Xaml.Window または Microsoft.UI.Windowing.AppWindow に変換することはできません。
以下のように記述する必要があります:
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()!;
// タイトルバーを拡張するには、カスタムタイトルバーの色を設定する場合 true
nativeWindow.ExtendsContentIntoTitleBar = true;
// ここでは Overlapped に設定する必要があります。その後ウィンドウの Presenter は OverlappedPresenter になり、制御が容易になります
appWindow.SetPresenter(AppWindowPresenterKind.Overlapped);
//if (appWindow.Presenter is OverlappedPresenter p)
//{
// // p.SetBorderAndTitleBar(hasBorder: false, hasTitleBar: true);
//}
// デフォルトの開くサイズを再設定
appWindow.MoveAndResize(new RectInt32(1920 / 2 - width / 2, 1080 / 2 - height / 2, width, height));
// ウィンドウサイズ変更に関する各種イベント
appWindow.Changed += (w, e) =>
{
// 位置が変更された場合
if (e.DidPositionChange)
{
}
if (e.DidPresenterChange) { }
// サイズが変更された場合
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, "WebHost を閉じれません");
ProcessManager.ReleaseLock();
}
finally
{
ProcessManager.ExitProcess(0);
}
};
appWindow.MoveInZOrderAtTop();
}
また、現在のウィンドウインスタンスを直接取得することも面倒です。
以下のコードで現在のプログラムが開いているすべてのウィンドウを取得できます。
App.Current.Windows
Application.Current.Windows
現在使用中またはアクティブなウィンドウを取得したい場合、API 経由で取得する方法は筆者は知りません……Win32 を使えば可能ですが。
質問:そのような API は存在しますか?
Current.GetWindos()
さらに、MAUI ではカスタムタイトルバーは実現できません。誰が何と言おうと無理です。
タイトルバーの背景色を変更するだけで、おそらく死ぬほど大変です。
ウィンドウのタイトルを変更するには、ウィンドウ作成時のみ変更可能です。つまり Microsoft.Maui.Controls.Windows を使用し、Microsoft.UI.Xaml.Window や Microsoft.UI.Windowing.AppWindow では変更できません。
Microsoft.Maui.Controls.Window window = base.CreateWindow(activationState);
window.Title = Constants.Name;
ネイティブの Window ハンドルを取得するには、以下を使用します:
var nativeWindow = mauiWindow.Handler.PlatformView;
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(nativeWindow);
ウィンドウ管理
前述のとおり、ウィンドウを管理するには、API は Microsoft.UI.Xaml.Window または Microsoft.UI.Windowing.AppWindow を使用する必要があります。
一部の箇所では、ネイティブのウィンドウハンドルを使用し、Win32 で操作する必要があります。
カスタムウィンドウライフサイクルを使用する場合は、必ず以下を使用します:
// ここでは Overlapped に設定する必要があります。その後ウィンドウの Presenter は OverlappedPresenter になり、制御が容易になります
appWindow.SetPresenter(AppWindowPresenterKind.Overlapped);
よく使うウィンドウメソッド:
/*
AppWindow の Presenter は必ず OverlappedPresenter です
*/
public class WindowService : IWindowService
{
private readonly AppWindow _appWindow;
private readonly Window _window;
private WindowService(AppWindow appWindow, Window window)
{
_appWindow = appWindow;
_window = window;
}
// 現在のウィンドウが全画面かどうかを確認
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;
}
}
}
// ウィンドウを全画面にする
public void FullScreen()
{
switch (_appWindow.Presenter)
{
case OverlappedPresenter overlappedPresenter:
overlappedPresenter.SetBorderAndTitleBar(true, true);
overlappedPresenter.Maximize();
break;
}
// 全画面時にタスクバーを非表示にする
// _appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
}
// 全画面を解除
public void ExitFullScreen()
{
switch (_appWindow.Presenter)
{
case OverlappedPresenter p: p.Restore();break;
default: _appWindow.SetPresenter(AppWindowPresenterKind.Default); break;
}
}
// タスクバーに最小化
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>
/// 現在のウィンドウをアクティブにする
/// </summary>
public void Active()
{
_appWindow.Show(true);
}
// ウィンドウを閉じる
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);
}
}
ウィンドウを全画面にする方法は2つあります。1つは全画面時にウィンドウがタスクバーを隠す、真の全画面。もう1つはタスクバーを残す方法です。
// タスクバーを残す
switch (_appWindow.Presenter)
{
case OverlappedPresenter overlappedPresenter:
overlappedPresenter.SetBorderAndTitleBar(true, true);
overlappedPresenter.Maximize();
break;
}
// 全画面時にタスクバーを非表示にする
// _appWindow.SetPresenter(AppWindowPresenterKind.FullScreen);
最小化は Win32 API でのみ処理できます。まず Microsoft.Maui.Controls.Windows を取得し、Window ハンドルに変換します。
var nativeWindow = mauiWindow.Handler.PlatformView;
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(nativeWindow);
PInvoke.User32.ShowWindow(windowHandle, PInvoke.User32.WindowShowStyle.SW_MINIMIZE);
この時点では、
Microsoft.UI.Xaml.WindowやMicrosoft.UI.Windowing.AppWindowは使用できません。
前述のように、Microsoft.UI.Xaml.Window または Microsoft.UI.Windowing.AppWindow を使用する必要があります。例えば MauiProgram.cs でウィンドウのイベントを記録し、ウィンドウ作成時にサイズを制御するなどです。
ただし、ウィンドウ実行中にサイズを設定または制限する場合は、Microsoft.Maui.Controls.Windows を介して行う必要があります。
例えば、メインウィンドウのサイズが小さくなりすぎないように制限するには、App.cs で次のように記述します:
protected override Window CreateWindow(IActivationState? activationState)
{
Window window = base.CreateWindow(activationState);
window.Title ="ウィンドウタイトル";
var minSize = GetMinSize();
window.MinimumWidth = minSize.MinWidth;
window.MinimumHeight = minSize.MinHeight;
// ウィンドウがリサイズされる時間を与える
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()
{
// 現在の画面の幅と高さを X、Y で取得する
var x = PInvoke.User32.GetSystemMetrics(PInvoke.User32.SystemMetric.SM_CXFULLSCREEN);
var y = PInvoke.User32.GetSystemMetrics(PInvoke.User32.SystemMetric.SM_CYFULLSCREEN);
// ウィンドウの最小値を設定。比率に応じて計算するか、固定サイズを直接設定も可能
return (x / 3 * 2, y / 5 * 4);
}
}
補足:AppWindow 内のイベントでサイズ制限を行うことはできません。これは主に観察用で、ウィンドウサイズの制限などは行えません。
// ウィンドウサイズ変更に関する各種イベント
appWindow.Changed += (w, e) =>
{
// 位置が変更された場合
if (e.DidPositionChange)
{
}
if (e.DidPresenterChange) { }
// サイズが変更された場合
if (e.DidSizeChange) { }
if (e.DidVisibilityChange) { }
if (e.DidZOrderChange) { }
};
一度に1つのプログラムだけを起動するように制限する方法
シナリオ:プログラム D がすでにプロセス A として実行されている場合、再度プログラム D を起動してプロセス B を実行しようとすると、B は既に同じプロセスが存在することを認識し、B が A のウィンドウをアクティブにして前面に表示し、その後 B が終了します。
これにより、1つのプロセスだけを実行するように制限できるだけでなく、ユーザーエクスペリエンスも向上します。
ロックには Mutex を使用します。オペレーティングシステム全体で同じロックを認識できます。
そして別のウィンドウをアクティブにするには、Win32 を使用します。
// プロセスマネージャ
internal static class ProcessManager
{
private static Mutex ProcessLock;
private static bool HasLock;
/// <summary>
/// プロセスロックを取得
/// </summary>
public static void GetProcessLock()
{
// グローバルロック
ProcessLock = new Mutex(false, "Global\\" + "カスタムロック名", out HasLock);
if (!HasLock)
{
ActiveWindow();
Environment.Exit(0);
}
}
/// <summary>
/// 現在のプロセスをアクティブにし、そのウィンドウを画面の最前面に表示
/// </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>
/// 現在のプロセスのロックを解放
/// </summary>
/// <remarks>注意して使用してください</remarks>
public static void ReleaseLock()
{
if (ProcessLock != null && HasLock)
{
ProcessLock.Dispose();
HasLock = false;
}
}
// 別のウィンドウをアクティブにして前面に表示する
// Win32 API
[DllImport("user32.dll")]
public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);
}
そしてプログラム起動時に使用します。

MAUI プログラムのインストールモード
ネイティブの MAUI プログラムを直接使用すると、インストールが非常に面倒です。なぜならこの方式は従来の UWP と同じだからです。
そのため、インストール不要で直接実行できる方式を使用できます。
ただし、ここでは2つのモードの違いを理解しておく必要があります。
ネイティブの MAUI モードを使用する場合、Windows アプリストアアプリとして生成され、公開、ストアへのアップロード、インストールのすべてが非常に面倒です。ただし、Windows アプリの多くの API を使用できる利点があります。
例えば、アプリケーションのインストールディレクトリを取得するには:
ApplicationData appdata = Windows.Storage.ApplicationData.Current
ローカルストレージディレクトリとテンポラリディレクトリを取得するには:
var localPath = Windows.Storage.ApplicationData.Current.LocalFolder.Path
var tempPath = Windows.Storage.ApplicationData.Current.TemporaryFolder.Path
通常のインストール場所:
"C:\\Users\\{ユーザー名}\\AppData\\Local\\Packages\\{プログラムGUID}
その他、言語処理などの API もあり、ストアアプリモードを使うと便利です。
次に、カスタムパッケージモードについて説明します。これは単にコンパイルしてファイル群を生成し、exe を直接起動して実行できるもので、インストールは不要です。インストーラを作成したい場合は、まず公開を行い、その後パッケージングツールでパッケージ化します。
プロジェクトファイルに以下の2行を追加するだけで実現できます:
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfConatined>true</WindowsAppSDKSelfConatined>
これにより、直接実行可能なファイルが生成され、インストールパッケージは不要になり、生成されたプログラムはクリックするだけで起動でき、インストール後の起動や証明書なしで実行できます。
MAUI Blazor に言語を設定する
MAUI Blazor は Windows 上で WebView2 を使用しています。MAUI Blazor の実行環境はプログラムとは無関係で、システム言語を中国語に設定しても、アセンブリに中国語を設定しても、ローカルカルチャを中国語に設定しても、CultureInfo を中国語に設定しても、まったく効果がありません。
プログラム起動後に F12 を押して JavaScript コードを実行し、ブラウザの実行環境の言語を確認できます:
navigator.language
'en-US'

または API を使用:
// using Windows.Globalization
var langs = ApplicationLanguages.Languages.ToList<string>();

落とし穴 ①
まず、Windows.Globalization を設定します:
ApplicationLanguages.PrimaryLanguageOverride = "zh-CN";
そしてプログラムを再起動すると、次のようになります:

しかしブラウザの言語環境は変化していません:

理由は、
Preferencesファイルを再生成しないと効果が現れないためです。後述します。
落とし穴 ②
プログラム起動後、{Windowsプログラムデータディレクトリ}/{プログラムID}/LocalState\EBWebView\Default に WebView2 のファイルが生成され、その中の Preferences ファイルに WebView2 のパラメータが設定されています。
自分のプログラムデータディレクトリを見つけます:
var path = Windows.Storage.ApplicationData.Current.LocalFolder.Path;

そのため、手動でファイルを修正し、WebView2 に中国語環境を使用させることができます。


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);
}
落とし穴 ③
最終的に、① の考え方は正しいが、効果が現れない理由は Preferences ファイルを削除して再作成する必要があるためであることがわかりました。プログラム起動時(WebView がまだ起動していない時)に中国語を設定すれば問題ありません。


コードを確認:
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);
}
}
// 多数のコードは省略
}
public static MauiApp CreateMauiApp() 内で使用:

MAUI プロジェクトを管理者権限で起動するよう構成する
問題の背景
Windows では、開発したアプリケーションが app.manifest アセットファイルを使用して、プログラム起動時のロール権限を構成できます。
効果は次のとおり:

通常、app.manifest に以下の構成を追加するだけで機能します:
プロジェクトにこのファイルがない場合は、プロジェクトに新しい項目 → マニフェストファイルを追加します。
<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>

しかし、MAUI アプリケーションでは追加できません。追加するとエラーが発生します。
Platforms\Windows\app.manifest : manifest authoring error c1010001: Values of attribute "level" not equal in different manifest snippets.
.NET コンパイラがデフォルトでプログラム用に app.manifest ファイルを生成しており、そのファイル内容にはすでに trustInfo 構成が含まれているためです。
プロジェクトで <WindowsAppSDKSelfConatined>true</WindowsAppSDKSelfConatined> を有効にしている場合は、Microsoft.WindowsAppSDK.SelfContained.targets ファイルを確認する必要があります:


そのため、app.manifest をカスタマイズするには、Microsoft.WindowsAppSDK.SelfContained.targets を変更するか、別の方法を取る必要があります。前者はあまり良くありません。
ビルドプロセスのカスタマイズ
ビルドプロセスを観察すると、manifest ファイルが obj ディレクトリに生成されることがわかります。

mergeapp.manifest はプロジェクト内の app.manifest であり、.NET コンパイル時に開発者のファイル名が変更されます。
プログラムのコンパイル時、まず Microsoft.WindowsAppSDK.SelfContained.targets からデフォルトの app.manifest が生成されます。
次に、開発者プロジェクトの app.manifest が mergeapp.manifest ファイルにコピーされ、mergeapp.manifest が app.manifest にマージされます。
app.manifest に既に構成が存在する場合、mergeapp.manifest 内の重複するレコードがコンパイルエラーを引き起こします。
このビルドプロセスを理解した上で、ビルド中に介入することができます。
app.manifest が生成された後、メインプログラムがコンパイルされる前に、app.manifest 内の構成を置き換えることができます。置き換えコマンドは次のとおり:
powershell -Command ";(gc app.manifest) -replace 'level=''asInvoker''', 'level=''requireAdministrator''' | Out-File -encoding ASCII app.manifest";

MSBuild のコンパイル手順に関するリファレンスは公式ドキュメントを参照:https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-targets?view=vs-2022
ビルドプロセスには2つの重要な環境変数があります:
_DeploymentManifestFiles:マニフェストファイルのディレクトリApplicationManifest:app.manifestファイルのパス
.csproj ファイルに以下のスクリプトを追加すると、プログラムのコンパイル時に自動的にマニフェストファイルが変更されます。
<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" は、GenerateManifests の前にカスタムコマンドを実行することを示します。
Condition="'$(PublishDir)' != ''" はトリガー条件で、MAUI では公開時のみこの変数が存在します。Condition="'$(Release)' != ''" に変更することもできます。
注意:場合によっては _DeploymentManifestFiles ディレクトリが存在しないことがあるため、何度かテストしてください。
最も確実な方法:
<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>
ビルドプロセスは主に以下の3つの手順で構成され、そのうち GenerateManifests のみ .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)">
取得可能なパラメータ:
$(_DeploymentBaseManifest);
@(ResolvedIsolatedComModules);
@(_DeploymentManifestDependencies);
@(_DeploymentResolvedManifestEntryPoint);
@(_DeploymentManifestFiles)
@(ApplicationManifest)
MAUI でフロントエンド/バックエンド分離開発を実現する
背景
当初は Maui + Blazor 開発を採用し、コミュニティでホットな Blazor UI フレームワークを使用していました。
しかし開発を進めるうちに、Maui には多くの落とし穴があり、Blazor UI フレームワークにも多くの落とし穴があることが判明しました。Blazor UI で書かれたページとスタイルは、デザイナーやプロダクトマネージャーの目に適いませんでした。
最終的に、ネイティブフロントエンドと組み合わせて静的コンテンツを生成し、それを Maui に配置して、両者を結合してパッケージ公開することにしました。
まずフロントエンドを作成
フロントエンドについては、通常の開発モードに従えばよく、フロントエンドのコードに汚染を与えません。
VS を使用してフロントエンドプロジェクトを作成し、ソリューション内に配置するか、別のディレクトリを作成してフロントエンドコードを内部に配置することもできます。

MAUI Blazor プロジェクトを作成
MAUI Blazor プロジェクトを作成し、ソリューションは以下のようになります:

まず wwwroot/css/app.css ファイルを移動し、wwwroot に配置します。次に新しい app.js も wwwroot に作成します。
app.css ファイルの内容から Blazor に関係のない部分を削除し、以下の内容のみを残します:
#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);
}
}
次に、以下のコードを app.js に配置し、フロントエンドで生成された css ファイルを動的にインポートします。
function InitializeCss(name) {
document.getElementById("app-css").innerHTML =
'<link rel="stylesheet" href="' + name + '">';
}
そして js、css ディレクトリを削除します。
残りのファイルは図のようになります:

次に index.html を修正します。内容は以下の通り:
<!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>
追加された <div id="app-css"></div> は、css ファイルを動的に読み込むためのものです。
他の内容は基本的に変更しません。
MainLayout.razor を開き、ここでフロントエンドファイルを動的に読み込む役割を担います。その内容は以下の通り:
@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.ApplicationModel.Package.Current.InstalledLocation.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}");
}
}
}
}
そして、フロントエンドで生成されたコンテンツを自動的に Maui にコピーするために、Maui の .csproj に以下の内容を追加します:
<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>

これにより、Maui プロジェクトを起動すると、フロントエンドプロジェクトがコンパイルされ、生成されたファイル(index.html は除く)が wwwroot ディレクトリにコピーされます。
起動後:

C# での自動証明書生成、ローカルインストール、信頼証明書の問題解決
背景
Blazor の開発環境は https://0.0.0.0/ であるため、MAUI Blazor では https リクエストのみ送信でき、http リクエストや安全でない https リクエストを送信することはできません。しかし内部ネットワークの https は安全ではありません。
そのため、ローカルでプロキシサービスを実装し、アプリケーションが https 経由でアクセスできるようにする必要があります。https を安全にするために、自動証明書をインストールする必要があります。

これにより、JavaScript からリクエストを送信できなくなります。
https を安全にするために、ここではローカルの localhost で自動的に証明書を生成し、インストールするプロセスを実装します。
コードを書く
証明書の生成には .NET 標準ライブラリを使用し、サードパーティパッケージの導入は不要です。
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
証明書生成の方法は、https://github.com/dotnetcore/FastGitHub プロジェクトを参考にしています。
最初のステップは証明書ジェネレータを作成することです。コードはここから直接コピーします: https://github.com/dotnetcore/FastGitHub/blob/9f9cbce624310c207b01699de2a5818a742e11ca/FastGitHub.HttpServer/Certs/CertGenerator.cs
次に、証明書を管理するサービスを定義します。元の作者は .NET 7 を使用しており、現在の安定版は .NET 6 であるため、多くの API が使用できません。そのため改造が必要です。元のバージョンのアドレス: https://github.com/dotnetcore/FastGitHub/blob/9f9cbce624310c207b01699de2a5818a742e11ca/FastGitHub.HttpServer/Certs/CertService.cs
証明書の場所と名前を定義します:
private const string CACERT_PATH = "cacert";
/// <summary>
/// 証明書ファイルのパスを取得
/// </summary>
public string CaCerFilePath { get; } = OperatingSystem.IsLinux() ? $"{CACERT_PATH}/https.crt" : $"{CACERT_PATH}/https.cer";
/// <summary>
/// 秘密鍵ファイルのパスを取得
/// </summary>
public string CaKeyFilePath { get; } = $"{CACERT_PATH}/https.key";
ここでは2つのファイルが関わります。クライアント証明書と秘密鍵です。
.keyは秘密鍵で、秘密鍵からサーバー証明書とクライアント証明書を生成できます。そのため、ここでは.key秘密鍵のみ保存すればよく、サーバー証明書をエクスポートする必要はありません。.csr、.cerはクライアント証明書です。Windows では.cer形式を使用できます。クライアント証明書をエクスポートする理由は、証明書をインストールするためであり、1回インストールすれば十分で、動的に生成する必要はありません。
証明書管理サービスのルールは、ssl ディレクトリに証明書が存在しない場合は生成してインストールし、ファイルが既に存在する場合はファイルをメモリにロードし、再インストールは行わないというものです。
完全なコードは以下の通り:
/// <summary>
/// 証明書生成サービス
/// </summary>
internal class CertService
{
private const string CACERT_PATH = "cacert";
/// <summary>
/// 証明書ファイルのパスを取得
/// </summary>
public string CaCerFilePath { get; } = OperatingSystem.IsLinux() ? $"{CACERT_PATH}/https.crt" : $"{CACERT_PATH}/https.cer";
/// <summary>
/// 秘密鍵ファイルのパスを取得
/// </summary>
public string CaKeyFilePath { get; } = $"{CACERT_PATH}/https.key";
private X509Certificate2? caCert;
/*
ローカルには cer と key の2つのファイルが生成されます。cer ファイルは Windows 証明書マネージャーにインポートします。
key ファイルは起動時に X509 証明書を生成し、Web サービスで使用します。
*/
/// <summary>
/// CA 証明書を生成
/// </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=運連網物流管理系統");
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>
/// 指定されたドメインに発行する証明書を取得
/// </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!;
// ドメインの1年証明書を生成
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);
// Winプラットフォームではメモリ証明書が使用できないため、再初期化
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>
/// CA証明書をインストール
/// </summary>
/// <exception cref="Exception">証明書をインストールできない場合</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($"CA証明書 {caCertFilePath} を「すべての証明書を次のストアに配置する」→「信頼されたルート証明機関」に手動でインストールしてください。" + ex);
}
}
}
}
ASP.NET Core で使用する
ASP.NET Core でサーバー証明書を読み込みます(起動時に毎回 X509 証明書を生成します)。
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();