Add desktop lifecycle shutdown endpoints

Adds local-only desktop API endpoints and browser lifecycle script support so the app can schedule or cancel shutdown when the browser session ends.
main
SYED MUSTUFA AHMED NAQVI 2026-05-23 11:38:20 +05:00
parent a07dc11b75
commit cfddae7a80
4 changed files with 237 additions and 0 deletions

View File

@ -0,0 +1,72 @@
using System.Net;
using AVSCartonShipmentVerifier.Configuration;
using AVSCartonShipmentVerifier.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace AVSCartonShipmentVerifier.Controllers;
[ApiController]
[Route("api/desktop")]
public sealed class DesktopController : ControllerBase
{
private readonly DesktopShutdownService _shutdown;
private readonly IOptions<DesktopHostOptions> _options;
public DesktopController(DesktopShutdownService shutdown, IOptions<DesktopHostOptions> options)
{
_shutdown = shutdown;
_options = options;
}
[HttpPost("register-session")]
public IActionResult RegisterSession()
{
if (!IsShutdownEnabled() || !IsLocalRequest())
{
return NotFound();
}
_shutdown.MarkDashboardSessionActive();
_shutdown.CancelScheduledShutdown();
return Ok();
}
[HttpPost("schedule-shutdown")]
public IActionResult ScheduleShutdown()
{
if (!IsShutdownEnabled() || !IsLocalRequest())
{
return NotFound();
}
if (!_shutdown.IsLifecycleShutdownAllowed)
{
return Ok();
}
var delay = TimeSpan.FromSeconds(Math.Max(1, _options.Value.BrowserShutdownDelaySeconds));
_shutdown.ScheduleShutdown(delay);
return Ok();
}
[HttpPost("cancel-shutdown")]
public IActionResult CancelShutdown()
{
if (!IsShutdownEnabled() || !IsLocalRequest())
{
return NotFound();
}
_shutdown.CancelScheduledShutdown();
return Ok();
}
private bool IsShutdownEnabled() => _options.Value.ExitWhenBrowserCloses;
private bool IsLocalRequest()
{
var remote = HttpContext.Connection.RemoteIpAddress;
return remote is null || IPAddress.IsLoopback(remote);
}
}

View File

@ -0,0 +1,117 @@
using System.Diagnostics;
namespace AVSCartonShipmentVerifier.Hosting;
/// <summary>
/// Coordinates graceful host shutdown when the desktop browser window or tab is closed.
/// </summary>
public sealed class DesktopShutdownService
{
private readonly IHostApplicationLifetime _lifetime;
private readonly ILogger<DesktopShutdownService> _logger;
private readonly object _gate = new();
private CancellationTokenSource? _delayedShutdownCts;
private int _shutdownInitiated;
private int _dashboardSessionActive;
public DesktopShutdownService(
IHostApplicationLifetime lifetime,
ILogger<DesktopShutdownService> logger)
{
_lifetime = lifetime;
_logger = logger;
}
public void MarkDashboardSessionActive()
{
Interlocked.Exchange(ref _dashboardSessionActive, 1);
}
public bool IsLifecycleShutdownAllowed => Volatile.Read(ref _dashboardSessionActive) == 1;
public void RequestShutdown(string reason)
{
if (Interlocked.Exchange(ref _shutdownInitiated, 1) != 0)
{
return;
}
_logger.LogInformation("Stopping application: {Reason}", reason);
_lifetime.StopApplication();
}
public void ScheduleShutdown(TimeSpan delay)
{
if (!IsLifecycleShutdownAllowed)
{
_logger.LogDebug("Ignored lifecycle shutdown request before dashboard session was active.");
return;
}
lock (_gate)
{
_delayedShutdownCts?.Cancel();
_delayedShutdownCts?.Dispose();
_delayedShutdownCts = new CancellationTokenSource();
var token = _delayedShutdownCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(delay, token).ConfigureAwait(false);
RequestShutdown("Browser session ended");
}
catch (OperationCanceledException)
{
// Reload or cancel-shutdown superseded this timer.
}
}, token);
}
}
public void CancelScheduledShutdown()
{
lock (_gate)
{
if (_delayedShutdownCts is null)
{
return;
}
_delayedShutdownCts.Cancel();
_delayedShutdownCts.Dispose();
_delayedShutdownCts = null;
}
}
public void WatchBrowserProcess(Process process, TimeSpan minimumLifetime)
{
var processId = process.Id;
var startedUtc = DateTime.UtcNow;
_ = Task.Run(async () =>
{
try
{
await process.WaitForExitAsync().ConfigureAwait(false);
var lifetime = DateTime.UtcNow - startedUtc;
if (lifetime < minimumLifetime)
{
_logger.LogInformation(
"Browser launcher process (PID {ProcessId}) exited after {LifetimeSeconds:F1}s; likely handoff to an existing browser. Process tracking disabled; application will keep running.",
processId,
lifetime.TotalSeconds);
return;
}
RequestShutdown("Browser window closed");
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Browser process watch ended unexpectedly for PID {ProcessId}.", processId);
}
});
}
}

View File

@ -1,3 +1,6 @@
@using AVSCartonShipmentVerifier.Configuration
@using Microsoft.Extensions.Options
@inject IOptions<DesktopHostOptions> DesktopHostOptions
<!DOCTYPE html>
<html lang="en">
<head>
@ -15,6 +18,10 @@
</div>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
@if (DesktopHostOptions.Value.ExitWhenBrowserCloses)
{
<script src="~/js/desktop-lifecycle.js" asp-append-version="true"></script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,41 @@
(() => {
const scheduleUrl = "/api/desktop/schedule-shutdown";
const cancelUrl = "/api/desktop/cancel-shutdown";
const registerUrl = "/api/desktop/register-session";
let sessionRegistered = false;
const post = (url) => {
if (typeof navigator.sendBeacon === "function") {
navigator.sendBeacon(url, "");
return;
}
fetch(url, { method: "POST", keepalive: true }).catch(() => { });
};
const registerSession = () => {
fetch(registerUrl, { method: "POST", keepalive: true })
.then((response) => {
if (response.ok) {
sessionRegistered = true;
post(cancelUrl);
}
})
.catch(() => { });
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", registerSession, { once: true });
} else {
registerSession();
}
window.addEventListener("pagehide", (event) => {
if (event.persisted || !sessionRegistered) {
return;
}
post(scheduleUrl);
});
})();