Improve desktop browser launch flow

Adds readiness probing, app-mode Edge/Chrome launch support, browser process tracking, and conditional HTTPS redirection for desktop hosting.
main
SYED MUSTUFA AHMED NAQVI 2026-05-23 11:39:36 +05:00
parent cfddae7a80
commit 47620fad08
4 changed files with 236 additions and 17 deletions

View File

@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace AVSCartonShipmentVerifier.Controllers;
/// <summary>
/// Lightweight endpoints for desktop host startup (no database or dashboard work).
/// </summary>
[ApiController]
public sealed class DesktopHostController : ControllerBase
{
[HttpGet("/health")]
[HttpGet("/api/desktop-host/ready")]
public IActionResult Ready() => Ok(new { ready = true });
}

View File

@ -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<string> 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<string> 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");
}
}

View File

@ -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;
}
}

View File

@ -1,31 +1,37 @@
using System.Diagnostics; using System.Net.Http;
using AVSCartonShipmentVerifier.Configuration; using AVSCartonShipmentVerifier.Configuration;
using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace AVSCartonShipmentVerifier.Hosting; namespace AVSCartonShipmentVerifier.Hosting;
/// <summary> /// <summary>
/// Opens the default browser once the web server has started (Windows desktop-style launch). /// Opens the default browser once the web server has started (Windows desktop-style launch).
/// Stops the host when the browser window closes when <see cref="DesktopHostOptions.ExitWhenBrowserCloses"/> is enabled.
/// </summary> /// </summary>
internal sealed class LaunchBrowserHostedService : IHostedService 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 IHostApplicationLifetime _lifetime;
private readonly IOptions<DesktopHostOptions> _options; private readonly IOptions<DesktopHostOptions> _options;
private readonly IServer _server; private readonly IServer _server;
private readonly DesktopShutdownService _shutdown;
private readonly ILogger<LaunchBrowserHostedService> _logger; private readonly ILogger<LaunchBrowserHostedService> _logger;
public LaunchBrowserHostedService( public LaunchBrowserHostedService(
IServer server, IServer server,
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
IOptions<DesktopHostOptions> options, IOptions<DesktopHostOptions> options,
DesktopShutdownService shutdown,
ILogger<LaunchBrowserHostedService> logger) ILogger<LaunchBrowserHostedService> logger)
{ {
_server = server; _server = server;
_lifetime = lifetime; _lifetime = lifetime;
_options = options; _options = options;
_shutdown = shutdown;
_logger = logger; _logger = logger;
} }
@ -33,13 +39,15 @@ internal sealed class LaunchBrowserHostedService : IHostedService
{ {
var opts = _options.Value; var opts = _options.Value;
if (!opts.LaunchBrowser) if (!opts.LaunchBrowser)
{
return Task.CompletedTask; return Task.CompletedTask;
}
_lifetime.ApplicationStarted.Register(OpenBrowser); _lifetime.ApplicationStarted.Register(() => _ = OpenBrowserAsync());
return Task.CompletedTask; return Task.CompletedTask;
} }
private void OpenBrowser() private async Task OpenBrowserAsync()
{ {
try try
{ {
@ -50,17 +58,34 @@ internal sealed class LaunchBrowserHostedService : IHostedService
return; 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) catch (Exception ex)
@ -69,19 +94,64 @@ internal sealed class LaunchBrowserHostedService : IHostedService
} }
} }
private string? ResolveUrl() private static async Task<bool> 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; var configured = _options.Value.BrowserUrl;
if (!string.IsNullOrWhiteSpace(configured)) if (!string.IsNullOrWhiteSpace(configured))
return configured; {
return configured.Trim();
}
var addresses = _server.Features.Get<IServerAddressesFeature>()?.Addresses; var addresses = _server.Features.Get<IServerAddressesFeature>()?.Addresses;
if (addresses is null || addresses.Count == 0) if (addresses is { Count: > 0 })
return null; {
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)) if (!string.IsNullOrWhiteSpace(address))
?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) {
?? addresses.FirstOrDefault(); 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; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;