diff --git a/.gitignore b/.gitignore index fcb09c0..6a1c994 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ bin/ obj/ +# Publish artifacts +publish/ +artifacts/ +artifacts/** + # .NET Core *.runtimeconfig.dev.json @@ -45,4 +50,7 @@ node_modules/ *.temp # Environment variables -.env \ No newline at end of file +.env + +# Local overrides (connection strings, machine-specific paths). Copy from appsettings.Example.json. +appsettings.Local.json \ No newline at end of file diff --git a/AVSCartonShipmentVerifier.csproj b/AVSCartonShipmentVerifier.csproj index 9b56a64..22211d4 100644 --- a/AVSCartonShipmentVerifier.csproj +++ b/AVSCartonShipmentVerifier.csproj @@ -6,6 +6,10 @@ enable AVSCartonShipmentVerifier AVSCartonShipmentVerifier + AVSCartonShipmentVerifier-d4f8e9b2-3c1a-4f6e-9b7d-2a8e5c1f9d3b + + WinExe + Exe @@ -17,4 +21,9 @@ + + + + + diff --git a/Configuration/DesktopHostOptions.cs b/Configuration/DesktopHostOptions.cs new file mode 100644 index 0000000..275b398 --- /dev/null +++ b/Configuration/DesktopHostOptions.cs @@ -0,0 +1,16 @@ +namespace AVSCartonShipmentVerifier.Configuration; + +public sealed class DesktopHostOptions +{ + public const string SectionName = "DesktopHost"; + + /// + /// When true (default on Windows), opens the default browser after the server starts. + /// + public bool LaunchBrowser { get; init; } = true; + + /// + /// Optional URL to open. If empty, the first bound HTTP address from Kestrel is used. + /// + public string? BrowserUrl { get; init; } +} diff --git a/Hosting/LaunchBrowserHostedService.cs b/Hosting/LaunchBrowserHostedService.cs new file mode 100644 index 0000000..41ab7b7 --- /dev/null +++ b/Hosting/LaunchBrowserHostedService.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +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). +/// +internal sealed class LaunchBrowserHostedService : IHostedService +{ + private readonly IHostApplicationLifetime _lifetime; + private readonly IOptions _options; + private readonly IServer _server; + private readonly ILogger _logger; + + public LaunchBrowserHostedService( + IServer server, + IHostApplicationLifetime lifetime, + IOptions options, + ILogger logger) + { + _server = server; + _lifetime = lifetime; + _options = options; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + var opts = _options.Value; + if (!opts.LaunchBrowser) + return Task.CompletedTask; + + _lifetime.ApplicationStarted.Register(OpenBrowser); + return Task.CompletedTask; + } + + private void OpenBrowser() + { + try + { + var url = ResolveUrl(); + if (string.IsNullOrWhiteSpace(url)) + { + _logger.LogWarning("Could not determine URL to open in the browser."); + return; + } + + 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 }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to launch the default browser."); + } + } + + private string? ResolveUrl() + { + var configured = _options.Value.BrowserUrl; + if (!string.IsNullOrWhiteSpace(configured)) + return configured; + + var addresses = _server.Features.Get()?.Addresses; + if (addresses is null || addresses.Count == 0) + return null; + + return addresses.FirstOrDefault(static a => a.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + ?? addresses.FirstOrDefault(static a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + ?? addresses.FirstOrDefault(); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/Program.cs b/Program.cs index 1028f84..9ccea32 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using AVSCartonShipmentVerifier.Configuration; using AVSCartonShipmentVerifier.Data; +using AVSCartonShipmentVerifier.Hosting; using AVSCartonShipmentVerifier.Repositories; using AVSCartonShipmentVerifier.ScanHistory; using AVSCartonShipmentVerifier.Services; @@ -8,6 +9,8 @@ using Microsoft.Extensions.FileProviders; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); + builder.Services .AddOptions() .Bind(builder.Configuration.GetSection(CartonVerificationOptions.SectionName)) @@ -20,6 +23,12 @@ builder.Services .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(DesktopHostOptions.SectionName)); + +builder.Services.AddHostedService(); + builder.Services.AddControllersWithViews(); builder.Services.AddDbContext((serviceProvider, options) => @@ -40,6 +49,24 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); + + // EnsureCreated does not add new tables to an existing SQLite file; create rejected-scan storage explicitly. + db.Database.ExecuteSqlRaw( + """ + CREATE TABLE IF NOT EXISTS rejected_scan_entries ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + RawInputValue TEXT NOT NULL, + ModelNumber TEXT NOT NULL, + UniqueNumber TEXT NOT NULL, + RejectionReason TEXT NOT NULL, + ProcessedAtUtc TEXT NOT NULL + ); + """); + db.Database.ExecuteSqlRaw( + """ + CREATE INDEX IF NOT EXISTS IX_rejected_scan_entries_ProcessedAtUtc + ON rejected_scan_entries (ProcessedAtUtc); + """); } if (!app.Environment.IsDevelopment()) diff --git a/appsettings.Development.json b/appsettings.Development.json index a6e86ac..2c43f04 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -1,4 +1,10 @@ { + "ConnectionStrings": { + "VerificationDatabase": "Server=192.168.90.147;Port=3306;Database=UIND;User ID=utopia;Password=Utopia01;SslMode=None;Allow User Variables=True;" + }, + "DesktopHost": { + "LaunchBrowser": false + }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/appsettings.Example.json b/appsettings.Example.json new file mode 100644 index 0000000..68e2839 --- /dev/null +++ b/appsettings.Example.json @@ -0,0 +1,18 @@ +{ + "ConnectionStrings": { + "VerificationDatabase": "" + }, + + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5000" + } + } + }, + + "DesktopHost": { + "LaunchBrowser": true, + "BrowserUrl": "http://localhost:5000" + } +} diff --git a/appsettings.json b/appsettings.json index 8c200c6..60863ec 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,6 +1,15 @@ { - "ConnectionStrings": { - "VerificationDatabase": "" + "ConnectionStrings": {}, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5000" + } + } + }, + "DesktopHost": { + "LaunchBrowser": true, + "BrowserUrl": "" }, "CartonVerification": { "TableName": "carton_verification_log",