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
parent
cfddae7a80
commit
47620fad08
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
{
|
||||||
|
|
||||||
_lifetime.ApplicationStarted.Register(OpenBrowser);
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenBrowser()
|
_lifetime.ApplicationStarted.Register(() => _ = OpenBrowserAsync());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
return addresses.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
?? addresses.FirstOrDefault();
|
?? 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;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue