打造極簡式 ASP.NET Core 桌面小工具 - 網頁關閉自動結束
4 | 3,512 |
陸續介紹在 ASP.NET Core 專案內嵌 HTML、.js 跑介面、呼叫 Minimal API MapPost("...") 寫的 WebAPI,程式啟動時帶出客戶端瀏覽器開啟操作網頁,我們已可實現類似 Electron 用網頁寫桌面應用程式的效果,但有個問題。Electron 程式以瀏覽器核心為本體,關閉操作介面程式就關閉了,但透過 Process.Start 啟動的瀏覽器與 ASP.NET Core 程式各自獨立,使用者關閉網頁頁籤或瀏覽器,需切換到 ASP.NET Core 程式按 Ctrl-C 才會結束。
我想做到瀏覽器關閉網頁時程式也自動關閉,身為略懂前端開發的老鳥,這不算難事,這篇就來嘗試一下。
寫了一個窮極無聊的 Minimal API 小工具當範例 - GUID 產生器。運用內嵌 HTML 檔技巧,我寫了一個 ui\index.html 轉成內嵌資源:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GUID 產生器</title>
</head>
<body>
<button id=gen>NewGuid</button>
<input type=text id=guid size=36 />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$('#gen').click(function() {
$.post('/newguid').done(function(guid) {
// fill input and copy to clipboard
$('#guid').val(guid).select();
document.execCommand('copy');
});
})
</script>
</body>
</html>
Program.cs 則長這樣:
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Urls.Add("http://127.0.0.1:0");
app.UseFileServer(new FileServerOptions {
RequestPath = "",
FileProvider = new Microsoft.Extensions.FileProviders
.ManifestEmbeddedFileProvider(
typeof(Program).Assembly, "ui"
)
});
app.MapPost("/newguid", () => Guid.NewGuid().ToString());
var task = app.RunAsync();
if (!Debugger.IsAttached)
{
var url = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!
.Addresses.First();
Process.Start(new ProcessStartInfo("cmd", $"/c start {url}")
{ CreateNoWindow = true });
}
task.Wait();
至此,我們得到一個 EXE 檔 + HTML 介面 + ASP.NET Core Minimal API 小工具。(雖然用處不大)
要做到網頁關閉時程式自動結束,方法蠻多的,最簡單做法是在網頁跟 ASP.NET Core 間建立 Heartbeat 傳輸,只要網頁開啟就定期回傳我還活著的訊息,ASP.NET Core 端則會持續偵測訊息是否中斷,並在中斷一定時間後自行結束。網頁與網站持續傳送心跳的做法,從 setInterval()、Server-Sent Event HttpResponse.IsClientConnected 到 WebSocket 都是選項。考慮 setInterval 可能因為瀏覽器節流模式嚴重失準,我在 Server-Sent Event 與 WebSocket 間二選一,最後決定用 Server-Sent Event,實作起來比 WebSocket 簡單。
除了讓 ASP.NET Core 在網頁關閉後自動結束,我也希望若 ASP.NET Core 不慎被關閉時網頁也能偵測到結束作業,以下是我想到的雙向偵測原理:
- HTML 加上程式碼用 new EventSource('/sse') 開啟 SSE 連線,ASP.NET Core 端用 MapGet("/sse") 跑迴圈,每秒四次傳訊息到客戶端,期間會檢查 HttpContext.RequestAborted.IsCancellationRequested 偵測網頁是否關閉,若關閉則立即結束迴圈。
- MapGet("/sse") 每次執行 60 秒結束,網頁端 EventSource 會自動重連,預設重連時間為 3 秒,我透過訊息 retry 欄位(參考)將重新連線時間縮短到 250ms。
- MapGet("/sse") 每次被呼叫會將 zeroCount 計數器歸零,用 Task.Factory.StartNew() 檢查若連續兩秒 /sse 連線數掛零,則判斷已無網頁開啟,呼叫 IHostApplicationLifetime.StopApplication() 結束程式。
- HTML 只需一行 new EventSource('/sse') 即可實現偵測網頁是否關閉,但因為要偵測 ASP.NET Core 端是否關閉,我比照伺服器端做法,設了一個 disConnCount 計數器,在 EventSource.onmessage 事件收到訊息時歸零,另外用 setInterval 每秒遞增 disConnCount,若發現超過五秒未歸零,則停用網頁。
完整程式碼如下。Program.cs:
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Urls.Add("http://127.0.0.1:0");
app.UseFileServer(new FileServerOptions
{
RequestPath = "",
FileProvider = new Microsoft.Extensions.FileProviders
.ManifestEmbeddedFileProvider(
typeof(Program).Assembly, "ui"
)
});
app.MapPost("/newguid", () => Guid.NewGuid().ToString());
int sseCnnCount = 0;
bool sseTriggered = false;
int bps = 4;
app.MapGet("/sse", async (context) =>
{
sseTriggered = true;
var resp = context.Response;
Interlocked.Increment(ref sseCnnCount);
resp.Headers.Add("Content-Type", "text/event-stream");
try
{
//set reconnetion timeout
await resp.WriteAsync($"retry: {1000 / bps}\n\n");
for (int i = 0; i < 60 * bps; i++)
{
await resp.WriteAsync($"data: {i}\n\n");
await resp.Body.FlushAsync();
if (context.RequestAborted.IsCancellationRequested)
break;
await Task.Delay(1000 / bps);
}
}
finally
{
Interlocked.Decrement(ref sseCnnCount);
}
});
var task = app.RunAsync();
if (!Debugger.IsAttached)
{
var url = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!
.Addresses.First();
Process.Start(new ProcessStartInfo("cmd", $"/c start {url}")
{ CreateNoWindow = true });
}
// watch dog
var appLife = app.Services.GetRequiredService<IHostApplicationLifetime>();
Task.Factory.StartNew(async () => {
int zeroCount = 0;
while (!sseTriggered) {
await Task.Delay(1000);
}
while (zeroCount <= 2 * bps) {
if (sseCnnCount != 0) zeroCount = 0;
else zeroCount++;
await Task.Delay(1000 / bps);
}
appLife.StopApplication();
});
task.Wait();
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GUID 產生器</title>
</head>
<body>
<button id=gen>NewGuid</button>
<input type=text id=guid size=36 />
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$('#gen').click(function() {
$.post('/newguid').done(function(guid) {
$('#guid').val(guid).select();
document.execCommand('copy');
});
});
var evtSrc = new EventSource('/sse');
var disConnCount = 0;
evtSrc.onmessage = function(e) { disConnCount = 0; };
var hdn = setInterval(function() {
disConnCount++;
if (disConnCount >= 5) {
clearInterval(hdn);
evtSrc.close();
$('body').text('Disconnected');
}
}, 1000);
</script>
</body>
</html>
實測,關閉瀏覽器,/sse 請求隨即結束,ASP.NET Core 程式也會自動關閉。
反過來,若將 ASP.NET Core 程式關閉,網頁也會在五秒後停用切換為 Disconnected 訊息。
成功!
範例程式已放上 Github,有興趣玩玩的同學請自取。
This article provide a example to close ASP.NET Core programe when page is closed and disable the page when ASP.NET Core shutdown.
Comments
# by Joker
期待持續進化,黑大的想法真是有趣 看起來可以結合您的電子書功能成為小程式。
# by Saint
黑大好,跟你回報一個錯誤,點這連結-->程式啟動時帶出客戶端瀏覽器開啟操作網頁 會404,感謝你的分享
# by Jeffrey
to Saint,謝謝提醒,原連結有誤,已修正。
# by Quintos
感觉这种模式和 Blazor Server app很相似, 需要保持服务器端和浏览器页面的connection。