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",