愛用 .NET 寫桌面小工具的我,先前研究出「單一執行檔,啟動時自動啟動瀏覽器進入操作網頁,網頁關閉後自動結束程式」的優雅做法,還寫了 NuGet 程式庫簡化開發流程;一樣是借用 ASP.NET Core 技巧寫桌面程式,卻遠比 Eletron.NET 輕巧,我對這套自創做法還挺滿意的。(延伸閱讀:Electron.NET 太笨重?用 ASP.NET Core Minimal API 寫桌面小工具的快速做法)

但實務上還有另一類需求,桌面小工具偶爾才需顯示操作介面或報表、或本機端 WebAPI 供其他程式整合不需要 UI,就不適合上面說的「網頁關閉後自動結束程式」運作概念,更好的做法寫成常駐程式,在工作列放個小圖示靠右鍵選單關閉及執行自訂功能。

應用程式在工作列顯示小圖示是 Windows 標準 API 的一部分,術語叫 NotifyIcon,從 Windows Form/WPF/UWP 到 MAUI 都支援,但 ASP.NET Core 在桌面執行等同 Console Application,也能支援嗎?

我找到一個 NuGet 開源程式庫 - H.NotifyIcon,支援 .NET 6 WPF/WinUI/Uno.Skia.WPF/Console,使用上蠻直覺不難上手,省下自己造輪子的時間。除此之外,程式還用到以下技巧:

  1. 因 NotifyIcon 為 Windows 專屬功能,.csproj 要設定 <TargetFrameworks>net6.0-windows</TargetFrameworks>
  2. 若需限制程式只能跑一份(例如:本機 WebAPI 需要聽事先約定的 TCP Port 供其他軟體呼叫),可使用 Mutex 防止重複執行。參考:防止程式同時執行多份,比檢查Process清單更好的方法
  3. NotifyIcon 圖示要用的 Api.ico 檔要內嵌到 .exe 裡面,做法是在 .csproj 加上:
    <ItemGroup>
         <None Remove="App.ico" />
        <EmbeddedResource Include="App.ico" />
    </ItemGroup>
    

程式端使用 typeof(Program).Assembly.GetManifestResourceStream($"<the-namespace>.App.ico"); 取得 Stream 讀取內容。

  1. 工作列圖示選單通常要有 Exit 項目結束程式,我的做法是設一個 bool extiFlag,點 Exit 時將其設為 true,再配合以下寫法偵測 exitFlag 結束程式:
    var task = app.RunAsync();
    // ...
    var appLife = app.Services.GetRequiredService<IHostApplicationLifetime>();
    Task.Factory.StartNew(async () =>
    {
        while (!exitFlag)
        {
            await Task.Delay(100);
        }
        appLife.StopApplication();
    });
    
    task.Wait();
    
  2. 取得 ASP.NET Core 網站 Port 及啟動瀏覽器開啟網頁的做法可以參考:打造極簡式 ASP.NET Core 桌面小工具 - 動態 Port 與啟動瀏覽器
  3. 一般 Console 程式,執行時工具列會有對映的主控台項目,常駐程式不需要,將 .csproj 的 <OutputType>Exe</OutputType> 改成 <OutputType>WinExe</OutputType> 又沒顯示 Windows Form 時,在工作列就不會有任何項目。
  4. 在 .csproj 加入 ApplicationIcon 設定 .exe 的顯示圖示:
    <PropertyGroup>
        <ApplicationIcon>App.ico</ApplicationIcon>
    </PropertyGroup>
    
  5. 使用 dotnet publish -c Release -r win-x64 --no-self-contained -p:PublishSingleFile=true 建置成 512KB 大小的單一 exe 檔。(假設客戶端有安裝 .NET 6 SDK)

示範程式我借用了使用 Bouncy Castle DES/AES 加解密文章裡的 CodecNetFx 範例做 AES256 加解密,讓小工具有點用處。Program.cs 包含網頁的 HTML + JS,不到 120 行搞定:

using H.NotifyIcon.Core;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Diagnostics;
using MinApiTrayIcon;

const string appToolTip = "常駐小程式示範";
const string appUuid = "{9BE6C0F7-13F3-47BA-8B91-FB6A50BE09C5}";

// Prevent re-entrance
using (Mutex m = new Mutex(false, $"Global\\{appUuid}"))
{
    if (!m.WaitOne(0, false))
    {
        return;
    }

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

    app.MapGet("/", () => Results.Content(@"<!DOCTYPE html>
<html><head>
    <meta charset=utf-8>
    <title>AES256 Encryption/Decryption Demo</title>
    <style>
    textarea { width: 300px; display: block; margin-top: 3px; }
    div > * { margin-right: 3px; }
    </style>
</head>
<body>
    <div>
    <input id=key /><button onclick=encrypt()>Encrypt</button><button onclick=decrypt()>Decrypt</button>
    </div>
    <textarea id=plain></textarea>
    <textarea id=enc></textarea>
    <script>
    let setVal = (id,v) => document.getElementById(id).value=v;
    let val = (id) => document.getElementById(id).value;
    let getFetchOpt = (data) => {
        return {
            method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/plain' },
            body: JSON.stringify(data)
        }
    };
    function encrypt() { 
        setVal('enc', '');
        fetch('/enc',getFetchOpt({ key: val('key'), plain: val('plain') }))
        .then(r => r.text()).then(t => setVal('enc',t)); }
    function decrypt() { 
        setVal('plain', '');
        fetch('/dec',getFetchOpt({ key: val('key'), enc: val('enc') }))
        .then(r => r.text()).then(t => setVal('plain',t)); }
    </script>
</body></html>", "text/html"));
    Func<Func<string>, string> catchEx = (fn) =>
    {
        try { return fn(); } catch (Exception ex) { return "ERROR:" + ex.Message; }
    };
    app.MapPost("/enc", (DataObject data) => catchEx(() => AesUtil.AesEncrypt(data.key, data.plain)));
    app.MapPost("/dec", (DataObject data) => catchEx(() => AesUtil.AesDecrypt(data.key, data.enc)));

    var task = app.RunAsync();

    // Get web url
    var url = app.Services.GetRequiredService<IServer>()
        .Features.Get<IServerAddressesFeature>()
        .Addresses.First();

    // Tray Icon
    using var iconStream = typeof(Program).Assembly.GetManifestResourceStream($"MinApiTrayIcon.App.ico");
    using var icon = new Icon(iconStream);
    using var trayIcon = new TrayIconWithContextMenu
    {
        Icon = icon.Handle,
        ToolTip = appToolTip
    };
    trayIcon.ContextMenu = new PopupMenu()
    {
        Items =
        {
            new PopupMenuItem(url, (_, _) =>
            {
                Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") {
                    CreateNoWindow= true
                });
            }),
            new PopupMenuItem("Exit", (_, _)=>
            {
                trayIcon.Dispose();
                exitFlag = true;
            })
        }
    };
    trayIcon.Create();
    trayIcon.Show();

    var appLife = app.Services.GetRequiredService<IHostApplicationLifetime>();
    Task.Factory.StartNew(async () =>
    {
        while (!exitFlag)
        {
            await Task.Delay(100);
        }
        appLife.StopApplication();
    });

    task.Wait();
}

class DataObject
{
    public string key { get; set; }
    public string plain { get; set; }
    public string enc { get; set; }
}

來看最終成果:

展示影片

這種運作模式很適合長期在背景執行,不需或偶爾開啟介面的應用,像是監控服務、即時通知、讀卡機或其他週邊硬體整合... 等用途,隨便想都一堆可用情境。加入這種模式,未來 Minimal API 可應用的範圍又更廣了,讚!

老樣子,範例專案已上傳至 Github,有需要的同學請自取試玩。

This article demostrate how to create a ASP.NET Core Minimal API running in background and can be controlled via Windows tray icon.


Comments

Be the first to post a comment

Post a comment