4

把 ASP.NET Core 變成 Windows 桌面常駐程式

 1 year ago
source link: https://blog.darkthread.net/blog/min-api-run-with-tray-icon/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

把 ASP.NET Core 變成 Windows 桌面常駐程式-黑暗執行緒

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

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

Fig1_638110298032424600.gif

應用程式在工作列顯示小圖示是 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,有需要的同學請自取試玩。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK