講到用 ASP.NET Core 寫桌面 GUI 程式,大家通常會馬上想到 Electron.NET

Electron.NET 威力固然強大,對我來說卻太過笨重,開發需下載安裝 Electron CLI、額外設定,而發佈檔案因包含 Chromium,容量往往達到數百 MB,這個大小對 Teams、Slack、VSCode 等中大型應用程式仍算合理,若拿來寫批次改檔名、加解密之類的「小」工具便顯荒謬,也違背我追求的極簡風格。

認識 ASP.NET Core Minimal API 之後,我心生一計,想到可用 ASP.NET Core 專案內嵌 HTML、.js 跑介面、呼叫 Minimal API MapPost("...") 寫 WebAPI,再呼叫客戶端必備的瀏覽器開啟網站,如此靠一個小小 EXE 便能提供 UI 搞定大小事,再用點小技巧偵測瀏覽器退出網頁時關閉程式,除了沒有專屬桌面程式 GUI,其餘功能跟 Electron.NET 有 87 分像。最重要的是,Minimal API 用 .NET 6 標準發佈程序即可產生 .exe,手續簡便,執行檔又可以小到幾百 KB (若客戶端已有 .NET 6 SDK),與依賴 node.js 編譯發行、容量動轍數百 MB 的 Electron.NET 相比,該如何選擇,我毫無懸念。

先前已逐一打通用 ASP.NET Core Minimal API 寫桌面小工具的各個關鍵,包含:

嵌入靜態檔案是 ASP.NET Core 的內建功能,故專案還需設法讓 Minimal API 網站能動態決定 HTTP Port、啟動瀏覽器、偵測網頁結束自動關閉,我希望能將這些工作簡化到「參照 NuGet 套件、加一行程式搞定」。

於是我將前述的功能包成 NuGet Package,試著讓 ASP.NET Core Minimal API 程式用最小修改變身桌面小工具,本文會以一個 AES 加密小工具實地示範。

首先,我們先開啟一個 ASP.NET Core Minimal API 專案 dotnet new web -o AesEcryptor

為了實現單一 .exe 檔,參考嵌入 .html/.css/.js 靜態檔案徹底實現單一 exe 檔部署,我們建立一個 ui 資料夾,放入 HTML 及 js,並在 csproject 設定 GenerateEmbeddedFilesManifest、EmbeddedResource 及參照 Microsoft.Extensions.FileProviders.Embedded:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
  </PropertyGroup>
   <ItemGroup>
     <EmbeddedResource Include="ui\**" />
   </ItemGroup>
   <ItemGroup>
     <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="6.0.1" />
   </ItemGroup>
</Project>

雖然只是示範,介面太陽春也不好,於是我用 Vue.js 加上欄位有值才給按鈕的檢核、結果顯示變色提示,排版還套用 CSS Flex 樣式實現等比例縮放:

因為加了上述功能,程式碼略長,幸好沒超過 100 行。以下為 ui\index.html 的內容(記得 ui 下還要有 vue.global.prod.min.js,可從 CDN 下載)

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>AES Encryptor</title>
    <style>
        html,
        body {
            height: 100%;
            margin: 0;
            padding: 0;
        }

        .main {
            display: flex;
            flex-direction: column;   
            height: 98%;        
        }

        .main>* {
            margin: 0 6px;
            padding: 6px;
        }

        .plain {
            flex: 1 1 100px;
        }
        .btns {
            padding: 0 6px;
            flex: 0 0 25px;
        }
        .encrypted {
            flex: 2 2 200px;
        }

        textarea {
            box-sizing: border-box;
            height: 100%;
            width: 100%;
            font-size: 1.2em;
        }

        .btns>* {
            margin-right: 3px;
        }       
        textarea.hl {
            color: rgb(226, 0, 0);
            background-color: rgb(212, 252, 253);
        }
    </style>
</head>

<body>
    <div class="main" id="app">
        <form action="/aes" method="post" id="encForm" class="plain" target="postResult">
            <textarea name="data" v-model="Plain" :class="{hl:HighlightTarget=='Plain'}">Hello</textarea>
            <input type="hidden" name="mode" value="encrypt" />
            <input type="hidden" name="key" v-model="EncKey" />
        </form>
        <div class="btns">
            <input placeholder="Encryption Key..." v-model="EncKey" />
            <button @click="Encrypt()" :disabled="!EncKey || !Plain">Ecnrypt</button>
            <button @click="Decrypt()" :disabled="!EncKey || !Encrypted">Decrypt</button>
            <div>{{Message}}</div>
        </div>
        <form action="/aes" method="post" id="decForm" class="encrypted" target="postResult">
            <textarea class="encrypted" name="data" v-model="Encrypted"
                :class="{hl:HighlightTarget=='Encrypted'}"></textarea>
            <input type="hidden" name="mode" value="decrypt" />
            <input type="hidden" name="key" v-model="EncKey" />
        </form>
        </main>
        <iframe name="postResult" style="display:none"></iframe>
        <script src="vue.global.prod.min.js"></script>
        <script>
            var vm = Vue.createApp({
                data() {
                    return {
                        EncKey: 'ThisIsEncryptKey',
                        Plain: 'Hello World',
                        Encrypted: '',
                        Message: '',
                        HighlightTarget: ''
                    }
                },
                methods: {
                    Encrypt() { document.getElementById('encForm').submit(); },
                    Decrypt() { document.getElementById('decForm').submit(); },
                    Highlight(target) {
                        this.HighlightTarget = target;
                        let self = this;
                        setTimeout(() => self.HighlightTarget = '', 800);
                    }
                }
            }).mount('#app');
        </script>
</body>

</html>

Program.cs 借用使用 Bouncy Castle DES/AES 加解密的程式,使用 System.Security.Cryptograhpy.Aes 加解密:

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseFileServer(new FileServerOptions {
    RequestPath = "",
    FileProvider = new Microsoft.Extensions.FileProviders
                    .ManifestEmbeddedFileProvider(typeof(Program).Assembly, "ui") 
});

app.MapPost("/aes", (HttpContext ctx) => {
    var mode = ctx.Request.Form["mode"];
    var key = ctx.Request.Form["key"];
    var data = ctx.Request.Form["data"];
    var encMode = mode != "decrypt";
    var resProp = encMode ? "Encrypted" : "Plain";
    var message = string.Empty;
    var result = string.Empty;
    try {
        if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(data))
            throw new ArgumentException("parameter missing");
        result = encMode ? CodecNetFx.AesEncrypt(key, data) : CodecNetFx.AesDecrypt(key, data);
    }
    catch (Exception ex) { message = ex.Message;  }
    return Results.Content(@$"<script>
    parent.vm.{resProp} = {JsonSerializer.Serialize(result)};
    parent.vm.Message = {JsonSerializer.Serialize(message)};
    parent.vm.Highlight('{resProp}');
    </script>", "text/html");
});

app.Run();

public class CodecNetFx
{
        private class AesKeyIV
        {
            public Byte[] Key = new Byte[16], IV = new Byte[16];
            public AesKeyIV(string strKey)
            {
                var sha = SHA256.Create();
                var hash = sha.ComputeHash(Encoding.ASCII.GetBytes(strKey));
                Array.Copy(hash, 0, Key, 0, 16);
                Array.Copy(hash, 16, IV, 0, 16);
            }
        }
        public static string AesEncrypt(string key, string rawString)
        {
            var keyIv = new AesKeyIV(key);
            var aes = Aes.Create();
            aes.Key = keyIv.Key;
            aes.IV = keyIv.IV;
            var rawData = Encoding.UTF8.GetBytes(rawString);
            using (var ms = new MemoryStream())
            {
                using (var cs = new CryptoStream(ms, aes.CreateEncryptor(aes.Key, aes.IV), CryptoStreamMode.Write))
                {
                    cs.Write(rawData, 0, rawData.Length);
                    cs.FlushFinalBlock();
                    return Convert.ToBase64String(ms.ToArray());
                }
            }
        }

        public static string AesDecrypt(string key, string encString)
        {
            var keyIv = new AesKeyIV(key);
            var aes = Aes.Create();
            aes.Key = keyIv.Key;
            aes.IV = keyIv.IV;
            var encData = Convert.FromBase64String(encString);
            byte[] buffer = new byte[8192];
            using (var ms = new MemoryStream(encData))
            {
                using (var cs = new CryptoStream(ms, aes.CreateDecryptor(aes.Key, aes.IV), CryptoStreamMode.Read))
                {
                    using (var sr = new StreamReader(cs))
                    {
                        using (var dec = new MemoryStream())
                        {
                            cs.CopyTo(dec);
                            return Encoding.UTF8.GetString(dec.ToArray());
                        }
                    }
                }
            }
        }
    }
}

執行 dotnet publish -c Release --no-self-contained -r win-x64 -p:PublishSingleFile=true,至此我們得到一個 317KB 的 AesEncryptor.exe,執行後開瀏覽器連 localhost:5000 即可使用,要結束需自己按 Ctrl-C 關閉網站。

接下來便輪到 Drk.AspNetCore.MinimalApiKit NuGet Package 登場了!

使用方法為先 dotnet add package Drk.AspNetCore.MinimalApiKit 或用 NuGet Package Manager 安裝套件,接著做兩件事:

  1. 在 index.html 中加一行 <script src="/sse.js"></script>
  2. Program.cs 先加入 using Drk.AspNetCore.MinimalApiKit;,再將app.Run();改成app.RunAsDesktopTool()

小小改造後重新 dotnet publish 發佈,會發現 AesEncryptor.exe 變聰明了! 會自己找到可用的 HTTP Port 並啟動瀏覽器連到網站,瀏覽器關閉(或完全退出網頁)後幾秒程式將自動關閉。

是不是很方便呢?Drk.AspNetCore.MinimalApiKit 目前還算 Beta 測試階段,歡迎大家試用及回饋意見。

Example of how to use Drk.AspNetCore.MinimalApiKit to convert your Minimal API web to desktop tool.


Comments

# by Saint

黑大好,請問如果依此方式建立EXE後,發佈到其它機器上是否也需要安裝 core sdk 才能執行,謝謝

# by Jeffrey

to Saint, dotnet publish -c Release --self-contained -r win-x64 -p:PublishSingleFile=true <== 將參數改為 --self-contained ,exe 會變大(60MB 起跳)但不需安裝 .NET SDK 即可執行。參考:https://blog.darkthread.net/blog/dotnet6-publish-notes/

Post a comment