diff --git a/Controllers/DesktopHostController.cs b/Controllers/DesktopHostController.cs
new file mode 100644
index 0000000..d4c37f0
--- /dev/null
+++ b/Controllers/DesktopHostController.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace AVSCartonShipmentVerifier.Controllers;
+
+///
+/// Lightweight endpoints for desktop host startup (no database or dashboard work).
+///
+[ApiController]
+public sealed class DesktopHostController : ControllerBase
+{
+ [HttpGet("/health")]
+ [HttpGet("/api/desktop-host/ready")]
+ public IActionResult Ready() => Ok(new { ready = true });
+}
diff --git a/Hosting/DesktopBrowserLauncher.cs b/Hosting/DesktopBrowserLauncher.cs
new file mode 100644
index 0000000..5a31554
--- /dev/null
+++ b/Hosting/DesktopBrowserLauncher.cs
@@ -0,0 +1,115 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace AVSCartonShipmentVerifier.Hosting;
+
+internal static class DesktopBrowserLauncher
+{
+ internal sealed record LaunchResult(Process? TrackedProcess, bool UsedAppMode);
+
+ public static LaunchResult TryLaunch(string url, bool preferAppMode, string? userDataDir)
+ {
+ if (preferAppMode && OperatingSystem.IsWindows())
+ {
+ var profileDir = ResolveProfileDirectory(userDataDir);
+
+ foreach (var (fileName, arguments) in GetAppModeCandidates(url, profileDir))
+ {
+ if (!File.Exists(fileName))
+ {
+ continue;
+ }
+
+ try
+ {
+ var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ UseShellExecute = false
+ });
+
+ if (process is not null)
+ {
+ return new LaunchResult(process, UsedAppMode: true);
+ }
+ }
+ catch
+ {
+ // Try next browser.
+ }
+ }
+ }
+
+ try
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ Process.Start("open", url);
+ }
+ else
+ {
+ Process.Start(new ProcessStartInfo("xdg-open", url) { UseShellExecute = true });
+ }
+
+ return new LaunchResult(null, UsedAppMode: false);
+ }
+ catch
+ {
+ return new LaunchResult(null, UsedAppMode: false);
+ }
+ }
+
+ private static string? ResolveProfileDirectory(string? userDataDir)
+ {
+ if (string.IsNullOrWhiteSpace(userDataDir))
+ {
+ return null;
+ }
+
+ var profileDir = userDataDir.Trim();
+ Directory.CreateDirectory(profileDir);
+ return profileDir;
+ }
+
+ private static IEnumerable<(string FileName, string Arguments)> GetAppModeCandidates(string url, string? profileDir)
+ {
+ var builder = new StringBuilder();
+ builder.Append("--app=").Append(url);
+ builder.Append(" --new-window");
+
+ if (!string.IsNullOrWhiteSpace(profileDir))
+ {
+ var escapedProfile = profileDir.Replace("\"", "\\\"", StringComparison.Ordinal);
+ builder.Append(" --user-data-dir=\"").Append(escapedProfile).Append('"');
+ }
+
+ var appArgs = builder.ToString();
+
+ foreach (var edgePath in EdgePaths())
+ {
+ yield return (edgePath, appArgs);
+ }
+
+ foreach (var chromePath in ChromePaths())
+ {
+ yield return (chromePath, appArgs);
+ }
+ }
+
+ private static IEnumerable EdgePaths()
+ {
+ yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "Edge", "Application", "msedge.exe");
+ yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft", "Edge", "Application", "msedge.exe");
+ }
+
+ private static IEnumerable ChromePaths()
+ {
+ yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Google", "Chrome", "Application", "chrome.exe");
+ yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Google", "Chrome", "Application", "chrome.exe");
+ }
+}
diff --git a/Hosting/KestrelConfiguration.cs b/Hosting/KestrelConfiguration.cs
new file mode 100644
index 0000000..3a395e2
--- /dev/null
+++ b/Hosting/KestrelConfiguration.cs
@@ -0,0 +1,20 @@
+namespace AVSCartonShipmentVerifier.Hosting;
+
+internal static class KestrelConfiguration
+{
+ public static bool HasHttpsEndpoint(IConfiguration configuration)
+ {
+ var endpoints = configuration.GetSection("Kestrel:Endpoints").GetChildren();
+ foreach (var endpoint in endpoints)
+ {
+ var url = endpoint["Url"];
+ if (!string.IsNullOrWhiteSpace(url)
+ && url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Hosting/LaunchBrowserHostedService.cs b/Hosting/LaunchBrowserHostedService.cs
index 41ab7b7..829ca2b 100644
--- a/Hosting/LaunchBrowserHostedService.cs
+++ b/Hosting/LaunchBrowserHostedService.cs
@@ -1,31 +1,37 @@
-using System.Diagnostics;
+using System.Net.Http;
using AVSCartonShipmentVerifier.Configuration;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace AVSCartonShipmentVerifier.Hosting;
///
/// Opens the default browser once the web server has started (Windows desktop-style launch).
+/// Stops the host when the browser window closes when is enabled.
///
internal sealed class LaunchBrowserHostedService : IHostedService
{
+ private const string DefaultBrowserUrl = "http://127.0.0.1:5000";
+ private const string ReadinessPath = "/health";
+
private readonly IHostApplicationLifetime _lifetime;
private readonly IOptions _options;
private readonly IServer _server;
+ private readonly DesktopShutdownService _shutdown;
private readonly ILogger _logger;
public LaunchBrowserHostedService(
IServer server,
IHostApplicationLifetime lifetime,
IOptions options,
+ DesktopShutdownService shutdown,
ILogger logger)
{
_server = server;
_lifetime = lifetime;
_options = options;
+ _shutdown = shutdown;
_logger = logger;
}
@@ -33,13 +39,15 @@ internal sealed class LaunchBrowserHostedService : IHostedService
{
var opts = _options.Value;
if (!opts.LaunchBrowser)
+ {
return Task.CompletedTask;
+ }
- _lifetime.ApplicationStarted.Register(OpenBrowser);
+ _lifetime.ApplicationStarted.Register(() => _ = OpenBrowserAsync());
return Task.CompletedTask;
}
- private void OpenBrowser()
+ private async Task OpenBrowserAsync()
{
try
{
@@ -50,17 +58,34 @@ internal sealed class LaunchBrowserHostedService : IHostedService
return;
}
- if (OperatingSystem.IsWindows())
+ if (!await WaitForServerReadyAsync(url).ConfigureAwait(false))
{
- Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ _logger.LogWarning(
+ "Server did not respond on readiness endpoint before opening the browser at {Url}.",
+ url);
}
- else if (OperatingSystem.IsMacOS())
+
+ var opts = _options.Value;
+ var preferAppMode = opts.PreferAppModeBrowser && opts.ExitWhenBrowserCloses;
+ var launch = DesktopBrowserLauncher.TryLaunch(url, preferAppMode, opts.BrowserUserDataDir);
+ var minimumLifetime = TimeSpan.FromSeconds(Math.Max(1, opts.MinimumBrowserProcessLifetimeSeconds));
+
+ if (opts.ExitWhenBrowserCloses && launch.TrackedProcess is not null)
{
- Process.Start("open", url);
+ _shutdown.WatchBrowserProcess(launch.TrackedProcess, minimumLifetime);
+ _logger.LogInformation(
+ "Opened browser in app mode (PID {ProcessId}) at {Url} using profile {ProfileDir}. Application will exit when the window is closed.",
+ launch.TrackedProcess.Id,
+ url,
+ opts.BrowserUserDataDir);
+ return;
}
- else
+
+ if (opts.ExitWhenBrowserCloses)
{
- Process.Start(new ProcessStartInfo("xdg-open", url) { UseShellExecute = true });
+ _logger.LogInformation(
+ "Opened default browser at {Url}. Application will exit shortly after the app tab/window is closed.",
+ url);
}
}
catch (Exception ex)
@@ -69,19 +94,64 @@ internal sealed class LaunchBrowserHostedService : IHostedService
}
}
- private string? ResolveUrl()
+ private static async Task WaitForServerReadyAsync(string dashboardBaseUrl)
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ var probeUrl = dashboardBaseUrl.TrimEnd('/') + ReadinessPath;
+
+ for (var attempt = 0; attempt < 50; attempt++)
+ {
+ try
+ {
+ using var response = await client.GetAsync(probeUrl).ConfigureAwait(false);
+ if (response.IsSuccessStatusCode)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Server not ready yet.
+ }
+
+ await Task.Delay(100).ConfigureAwait(false);
+ }
+
+ return false;
+ }
+
+ private string ResolveUrl()
{
var configured = _options.Value.BrowserUrl;
if (!string.IsNullOrWhiteSpace(configured))
- return configured;
+ {
+ return configured.Trim();
+ }
var addresses = _server.Features.Get()?.Addresses;
- if (addresses is null || addresses.Count == 0)
- return null;
+ if (addresses is { Count: > 0 })
+ {
+ var address = addresses.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
+ ?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ ?? addresses.First();
- return addresses.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
- ?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
- ?? addresses.FirstOrDefault();
+ if (!string.IsNullOrWhiteSpace(address))
+ {
+ return NormalizeLoopbackAddress(address);
+ }
+ }
+
+ return DefaultBrowserUrl;
+ }
+
+ private static string NormalizeLoopbackAddress(string address)
+ {
+ if (address.Contains("://localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return address.Replace("://localhost", "://127.0.0.1", StringComparison.OrdinalIgnoreCase);
+ }
+
+ return address;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;