From cfddae7a80b47aa151a05b62df01729051a36f2e Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Sat, 23 May 2026 11:38:20 +0500 Subject: [PATCH] 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. --- Controllers/DesktopController.cs | 72 ++++++++++++++++++ Hosting/DesktopShutdownService.cs | 117 ++++++++++++++++++++++++++++++ Views/Shared/_Layout.cshtml | 7 ++ wwwroot/js/desktop-lifecycle.js | 41 +++++++++++ 4 files changed, 237 insertions(+) create mode 100644 Controllers/DesktopController.cs create mode 100644 Hosting/DesktopShutdownService.cs create mode 100644 wwwroot/js/desktop-lifecycle.js diff --git a/Controllers/DesktopController.cs b/Controllers/DesktopController.cs new file mode 100644 index 0000000..3ec70f7 --- /dev/null +++ b/Controllers/DesktopController.cs @@ -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 _options; + + public DesktopController(DesktopShutdownService shutdown, IOptions 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); + } +} diff --git a/Hosting/DesktopShutdownService.cs b/Hosting/DesktopShutdownService.cs new file mode 100644 index 0000000..32a54d6 --- /dev/null +++ b/Hosting/DesktopShutdownService.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; + +namespace AVSCartonShipmentVerifier.Hosting; + +/// +/// Coordinates graceful host shutdown when the desktop browser window or tab is closed. +/// +public sealed class DesktopShutdownService +{ + private readonly IHostApplicationLifetime _lifetime; + private readonly ILogger _logger; + private readonly object _gate = new(); + private CancellationTokenSource? _delayedShutdownCts; + private int _shutdownInitiated; + private int _dashboardSessionActive; + + public DesktopShutdownService( + IHostApplicationLifetime lifetime, + ILogger 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); + } + }); + } +} diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 7646d25..47bd8f5 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -1,3 +1,6 @@ +@using AVSCartonShipmentVerifier.Configuration +@using Microsoft.Extensions.Options +@inject IOptions DesktopHostOptions @@ -15,6 +18,10 @@ + @if (DesktopHostOptions.Value.ExitWhenBrowserCloses) + { + + } @await RenderSectionAsync("Scripts", required: false) diff --git a/wwwroot/js/desktop-lifecycle.js b/wwwroot/js/desktop-lifecycle.js new file mode 100644 index 0000000..eceb1ce --- /dev/null +++ b/wwwroot/js/desktop-lifecycle.js @@ -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); + }); +})();