天天看點

MAUI 初體驗以及 MAUI 入坑知識點

作者:DotNET技術圈

目錄:

  • 視窗
  • 視窗管理
  • 如何限制一次隻能打開一個程式
  • MAUI 程式安裝模式
  • 為 MAUI Blazor 設定語言
  • 配置 MAUI 項目使用管理者權限啟動
  • MAUI 實作前後端分離開發
  • C# 自動化生成證書、本地安裝證書、解決信任證書問題

這裡是筆者在開發 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 好很多,開發體驗也非常好,生成的軟體體積也是出奇的小,而且生成的 app 自帶安裝界面,生成的程式就是已經打包好的,省得自己手動重新打包。

文章介紹:https://www.whuanle.cn/archives/21062

與其他桌面開相比, MAUI 真的非常臃腫,而且 MAUI Blazor 開發得真的不爽,包括 Visual Studio 本身的開發體驗,架構本身的使用,以及 Blazor UI 架構,三個方面的體驗都不夠好。

能打的 Blazor 架構少,可以輕易擴充容易改造的 UI 架構更加少,目前發現能夠使用的 Blazor 架構,比較好的有 MASA Blazor。

為什麼這麼說呢,首先是 Blazor 編寫過程中,編輯器對 Razor 的支援不好,會經常出現沒有文法提示,代碼有錯誤但是編輯器沒有提示,編輯器提示有錯誤實際上代碼沒有錯誤,等等。。。

其次,關于 MAUI 下 Blazor 的使用和 Blazor 架構的選型。在 MAUI 下使用 Blazor,如果使用第三方 UI 架構,引入之後,會發現其天然有一種封閉性。

如果使用純前端架構開發,你會發現依賴引用關系很清晰,需要引用什麼包,編譯器會提示,編譯時會提示。

而 Blazor 架構,很難知道裡面用了哪些 js,Blazor dll 裡面嵌套了 js 等檔案,其本身就是一種封閉性,而關于内部的情況更加難以了解,出現了 Bug 調試難。

而且 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

MAUI 初體驗以及 MAUI 入坑知識點

MauiProgram.cs 中,有個

Microsoft.UI.Xaml.Window

,然後在 Windows 下

Microsoft.UI.Xaml.Window

Microsoft.Maui.MauiWinUIWindow

Microsoft.UI.Xaml.Window

多種平台統一的抽象。

MAUI 初體驗以及 MAUI 入坑知識點

然後

Microsoft.UI.Xaml.Window

可以擷取一個 AppWindow。

AppWindow appWindow = nativeWindow.GetAppWindow()!;           

MAUI 裡面的 Window 類 API 很混亂,大多數是從 UWP 寫法繼承,然後有很多 API 是 UWP 有的,但是 MAUI 沒有。

混亂。

如果自己寫了一個頁面,要彈出這個視窗頁面,那麼應該使用

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 != );
}
}
});           

雖然你獲得了

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, "Can't close 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

的。

有些地方隻能用原生的 Window 視窗句柄,然後用 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);
}
}           

讓視窗全屏有兩種方法,一種是全屏時,視窗把工作列吞了,真正意義上的的全屏,另一種是保留工作列。

// 保留工作列
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;

// 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()
{
// 擷取目前螢幕的長寬,用 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);
}
}           

PS,在 AppWindow 裡面的事件做大小限制,是做不到的,這裡主要是觀察,想做視窗大小等限制是不行的。

// 視窗調整的各類事件
 appWindow.Changed += (w, e) =>
{
// 位置發生變化
if (e.DidPositionChange) 
{

}
if (e.DidPresenterChange) { }
// 大小發生變化
if (e.DidSizeChange) { }
if (e.DidVisibilityChange) { }
if (e.DidZOrderChange) { }
};           

如何限制一次隻能打開一個程式

場景,如果程式D 已被運作 程序 A,那麼再次啟動程式D 運作程序 B,B 會識别到已有相同的程序,此時 B 會将 A 視窗激活彈出來,然後 B 再退出。

這樣不僅可以限制隻能運作一個程序,而且可以讓使用者體驗更加好。

鎖可以使用 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 !=  && HasLock)
{
 ProcessLock.Dispose();
 HasLock = false;
}
}

// 将另一個視窗激活放到前台。
// Win32 API
[DllImport("user32.dll")]
public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);
}           

然後在程式啟動時使用。

MAUI 初體驗以及 MAUI 入坑知識點

MAUI 程式安裝模式

如果直接使用原生的 MAUI 程式,安裝時會特别麻煩,因為這種方式就是以前的 UWP。

是以,可以使用那種,不需要安裝直接運作的方式。

但是這裡我們要了解一下兩種模式的差別。

如果使用原生 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 就能運作的,不需要安裝。如果想做成安裝包,可以先釋出,然後使用打包工具打包。

就是在項目檔案中加上這兩句即可:

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

之後會直接生成可執行檔案,不再需要安裝 MAUI 應用才行運作起來,也不需要證書才能運作。

為 MAUI Blazor 設定語言

MAUI Blazor 在 Windows 上使用的是 WebView2,MAUI Blazor 運作環境是跟程式沒關系的,即使是系統設定了中文語言,程式集設定了中文,本地文化設定了中文,CultureInfo 設定了中文,統統都沒有用。

你可以在程式啟動後,按下 F12,然後執行 JavaScript 代碼,檢查浏覽器的運作環境是何種語言:

navigator.language
'en-US'           
MAUI 初體驗以及 MAUI 入坑知識點

或者使用 API:

// using Windows.Globalization
var langs = ApplicationLanguages.Languages.ToList<string>();           
MAUI 初體驗以及 MAUI 入坑知識點

坑 ①

首先,設定 Windows.Globalization:

ApplicationLanguages.PrimaryLanguageOverride = "zh-CN";           

然後重新啟動程式,發現:

MAUI 初體驗以及 MAUI 入坑知識點

但是浏覽器語言環境依然沒有變化:

原因是

Preferences

檔案需要重新生成才能起效,後面會提到。

坑 ②

程式啟動後,會在

{Windows程式資料目錄}/{你的程式ID}/LocalState\EBWebView\Default

下面生成一些 WebView2 的檔案,其中 Preferences 檔案,裡面配置了 WebView2 的參數。

找到自己的程式資料目錄:

var path = Windows.Storage.ApplicationData.Current.LocalFolder.Path;           
MAUI 初體驗以及 MAUI 入坑知識點

是以,可以通過手動的方式修改檔案,讓 WebView2 使用中文環境。

MAUI 初體驗以及 MAUI 入坑知識點
MAUI 初體驗以及 MAUI 入坑知識點
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 尚未啟動),設定中文即可。

MAUI 初體驗以及 MAUI 入坑知識點
MAUI 初體驗以及 MAUI 入坑知識點

檢查代碼:

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 初體驗以及 MAUI 入坑知識點

配置 MAUI 項目使用管理者權限啟動

問題背景

在 Windows 中,開發的應用可以使用

app.manifest

資産檔案配置程式啟動時,使用何種角色權限啟動。

效果如下:

MAUI 初體驗以及 MAUI 入坑知識點

正常情況下,在

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 初體驗以及 MAUI 入坑知識點

但是在 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

檔案:

MAUI 初體驗以及 MAUI 入坑知識點
MAUI 初體驗以及 MAUI 入坑知識點

是以如果要自定義

app.manifest

,要麼就是把 Microsoft.WindowsAppSDK.SelfContained.targets 改了,但是這樣不太好。

定制編譯過程

如果觀察編譯過程,會發現

manifest

檔案會生成到

obj

目錄。

MAUI 初體驗以及 MAUI 入坑知識點

其中

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";           
MAUI 初體驗以及 MAUI 入坑知識點

MSBuild 編譯使用到的步驟可以參考官方文檔:

https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-targets?view=vs-2022

在編譯過程中,有兩個重要的環境變量:

_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>           

編譯過程主要在以下三步,其中隻有

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 初體驗以及 MAUI 入坑知識點

建立 MAUI Blazor 項目

建立 MAUI Blazor 項目,然後解決方案如下所示:

MAUI 初體驗以及 MAUI 入坑知識點

首先将

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

目錄。

剩下的檔案如圖所示:

MAUI 初體驗以及 MAUI 入坑知識點

然後修改

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.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}");
}
}
}
}           

然後為了能夠将前端生成的内容自動複制到 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 初體驗以及 MAUI 入坑知識點

這樣當啟動 Maui 項目時,會編譯前端項目,然後将生成的檔案(不包括 index.html) 複制到 wwwroot 目錄中。

啟動後:

MAUI 初體驗以及 MAUI 入坑知識點

C# 自動化生成證書、本地安裝證書、解決信任證書問題

背景

因為開發 Blazor 時 環境是

https://0.0.0.0/

,也就是 MAUI Blazor 中隻能發出 https 請求,既不能發出 http 請求,也不能發出不安全的 https 請求,但是内網的 https 是不安全的 https。

于是,隻能再本地實作一個代理服務,然後讓應用通過 https 通路,為了讓 https 安全,需要安裝自動化證書。

MAUI 初體驗以及 MAUI 入坑知識點

會導緻 js 發不出請求。

為了讓 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";           

這裡涉及到兩個檔案,用戶端證書和私鑰。

.key

是私鑰,可以通過私鑰來生成服務端證書和用戶端證書,是以這裡隻需要儲存

.key

私鑰,不需要導出伺服器證書。

.csr

.cer

是用戶端證書,在 Windows 下可以使用 .

cer

格式。導出用戶端證書的原因是要安裝證書,而且安裝一次即可,不需要動态生成。

證書管理服務的規則是,如果

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 兩個檔案,cer 檔案導入到 Window 證書管理器中。
 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 == )
{
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();            

繼續閱讀