using System.Net.Http; using AVSCartonShipmentVerifier.Configuration; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; 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; } public Task StartAsync(CancellationToken cancellationToken) { var opts = _options.Value; if (!opts.LaunchBrowser) { return Task.CompletedTask; } _lifetime.ApplicationStarted.Register(() => _ = OpenBrowserAsync()); return Task.CompletedTask; } private async Task OpenBrowserAsync() { try { var url = ResolveUrl(); if (string.IsNullOrWhiteSpace(url)) { _logger.LogWarning("Could not determine URL to open in the browser."); return; } if (!await WaitForServerReadyAsync(url).ConfigureAwait(false)) { _logger.LogWarning( "Server did not respond on readiness endpoint before opening the browser at {Url}.", url); } 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) { _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; } if (opts.ExitWhenBrowserCloses) { _logger.LogInformation( "Opened default browser at {Url}. Application will exit shortly after the app tab/window is closed.", url); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to launch the default browser."); } } 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.Trim(); } var addresses = _server.Features.Get()?.Addresses; 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(); 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; }