陸續介紹在 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 不慎被關閉時網頁也能偵測到結束作業,以下是我想到的雙向偵測原理:

  1. HTML 加上程式碼用 new EventSource('/sse') 開啟 SSE 連線,ASP.NET Core 端用 MapGet("/sse") 跑迴圈,每秒四次傳訊息到客戶端,期間會檢查 HttpContext.RequestAborted.IsCancellationRequested 偵測網頁是否關閉,若關閉則立即結束迴圈。
  2. MapGet("/sse") 每次執行 60 秒結束,網頁端 EventSource 會自動重連,預設重連時間為 3 秒,我透過訊息 retry 欄位(參考)將重新連線時間縮短到 250ms。
  3. MapGet("/sse") 每次被呼叫會將 zeroCount 計數器歸零,用 Task.Factory.StartNew() 檢查若連續兩秒 /sse 連線數掛零,則判斷已無網頁開啟,呼叫 IHostApplicationLifetime.StopApplication() 結束程式。
  4. 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。

Post a comment