把 ASP.NET Core 變成 Windows 桌面常駐程式
0 |
愛用 .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,使用上蠻直覺不難上手,省下自己造輪子的時間。除此之外,程式還用到以下技巧:
- 因 NotifyIcon 為 Windows 專屬功能,.csproj 要設定
<TargetFrameworks>net6.0-windows</TargetFrameworks>
。 - 若需限制程式只能跑一份(例如:本機 WebAPI 需要聽事先約定的 TCP Port 供其他軟體呼叫),可使用 Mutex 防止重複執行。參考:防止程式同時執行多份,比檢查Process清單更好的方法
- NotifyIcon 圖示要用的 Api.ico 檔要內嵌到 .exe 裡面,做法是在 .csproj 加上:
<ItemGroup> <None Remove="App.ico" /> <EmbeddedResource Include="App.ico" /> </ItemGroup>
程式端使用 typeof(Program).Assembly.GetManifestResourceStream($"<the-namespace>.App.ico");
取得 Stream 讀取內容。
- 工作列圖示選單通常要有 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();
- 取得 ASP.NET Core 網站 Port 及啟動瀏覽器開啟網頁的做法可以參考:打造極簡式 ASP.NET Core 桌面小工具 - 動態 Port 與啟動瀏覽器
- 一般 Console 程式,執行時工具列會有對映的主控台項目,常駐程式不需要,將 .csproj 的
<OutputType>Exe</OutputType>
改成<OutputType>WinExe</OutputType>
又沒顯示 Windows Form 時,在工作列就不會有任何項目。 - 在 .csproj 加入 ApplicationIcon 設定 .exe 的顯示圖示:
<PropertyGroup> <ApplicationIcon>App.ico</ApplicationIcon> </PropertyGroup>
- 使用
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