講到用 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

Be the first to post a comment

Post a comment


55 + 12 =