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;