瘋狂吐槽 maui 以及 maui 入坑知識點

瘋狂吐槽 maui 以及 maui 入坑知識點

這裡是筆者在開發 maui 應用時踩的坑,以及一些筆記的匯總。

最后更新 2023/1/18 下午10:02
痴者工良
预计阅读 30 分钟
分类
MAUI
标签
.NET MAUI

這裡是筆者在開發 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

MauiProgram.cs 中,有个 Microsoft.UI.Xaml.Window ,然后在 Windows 下 Microsoft.UI.Xaml.Window Microsoft.Maui.MauiWinUIWindowMicrosoft.UI.Xaml.Window 多种平台统一的抽象。

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

虽然你获得了 Microsoft.Maui.Controls.Window ,但是不能直接管理这个 Window,而是应该通过 Microsoft.UI.Xaml.WindowMicrosoft.UI.Windowing.AppWindow 管理。

也就是在依賴注入裡面的窗口生命周期管理裡面寫。

或者除非你可以拿到 appwindow 實例。

遗憾的是,Microsoft.Maui.Controls.Window 转不了 Microsoft.UI.Xaml.WindowMicrosoft.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 != null && HasLock)
        {
            ProcessLock.Dispose();
            HasLock = false;
        }
    }

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

然後在程式啟動時使用。

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 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);
        }
    }
// 省略N多代码
}

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

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

  • _DeploymentManifestFiles :清单文件所在目录;
  • ApplicationManifestapp.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 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 + '">';
}

然后删除 jscss 目录。

剩下的文件如圖所示:

然后修改 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 項目時,會編譯前端項目,然後將生成的文件(不包括 index.html) 複製到 wwwroot 目錄中。

啟動後:

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

背景

因为开发 Blazor 时 环境是 https://0.0.0.0/ ,也就是 MAUI Blazor 中只能发出 https 请求,既不能发出 http 请求,也不能发出不安全的 https 请求,但是内网的 https 是不安全的 https。 於是,只能再本地實現一個代理服務,然後讓應用通過 https 訪問,為了讓 https 安全,需要安裝自動化證書。

會導致 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 == 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();
Keep Exploring

延伸阅读

更多文章