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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -15,6 +18,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<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)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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