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
parent
a07dc11b75
commit
cfddae7a80
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
})();
|
||||
Loading…
Reference in New Issue