Creating a C# Launcher for CSharpDB Admin

CSharpDB Admin is an ASP.NET Core web app. That is a good fit for the tool itself: it can use the browser, Blazor Server, static assets, and the same hosting model developers already understand.

For an end user, though, the ideal flow is simpler: double-click an executable, let it start the local Admin site, and land on the home page in the default browser.

Why Publishing the Web App Is Not Enough

Publishing an ASP.NET Core project can produce a normal Windows executable. For example, CSharpDB Admin can be published as CSharpDB.Admin.exe. Running that executable starts the local Kestrel web server.

The part it does not do automatically is open the browser. The launchBrowser setting in Properties/launchSettings.json is a development-time setting used by Visual Studio and dotnet run. It is not part of the published app's runtime behavior.

Two Ways to Add Browser Launching

One option is to add browser-launch code directly to the Admin web app. After startup, the app can find its bound localhost URL and call Process.Start with UseShellExecute = true. That gives a true single-entry executable experience.

The other option is to keep the web app unchanged and create a tiny C# launcher executable. The launcher starts CSharpDB.Admin.exe, waits for the localhost port, and opens the browser. This keeps the desktop-style startup behavior separate from the web host.

For CSharpDB Admin, the launcher approach is a clean default. The Admin project stays a normal ASP.NET Core app, while the launcher owns the double-click experience.

The Launcher Program

The launcher should live beside the published Admin executable. At runtime it chooses a local port, passes that URL through ASPNETCORE_URLS, starts the Admin process, waits for the site to listen, and opens the default browser.

using System.Diagnostics;
using System.Net;
using System.Net.Sockets;

var port = GetAvailablePort();
var host = "127.0.0.1";
var baseUrl = $"http://{host}:{port}";
var homeUrl = $"{baseUrl}/";
var adminExe = Path.Combine(AppContext.BaseDirectory, "CSharpDB.Admin.exe");

if (!File.Exists(adminExe))
{
    Console.Error.WriteLine($"Could not find {adminExe}.");
    return 1;
}

var startInfo = new ProcessStartInfo
{
    FileName = adminExe,
    WorkingDirectory = AppContext.BaseDirectory,
    UseShellExecute = false
};

startInfo.Environment["ASPNETCORE_URLS"] = baseUrl;

using var adminProcess = Process.Start(startInfo);
if (adminProcess is null)
{
    Console.Error.WriteLine("Could not start CSharpDB.Admin.exe.");
    return 1;
}

try
{
    await WaitForPortAsync(host, port, adminProcess, TimeSpan.FromSeconds(30));

    Process.Start(new ProcessStartInfo
    {
        FileName = homeUrl,
        UseShellExecute = true
    });

    Console.WriteLine($"CSharpDB Admin is running at {homeUrl}");
    Console.WriteLine("Close this window or press Ctrl+C to stop the Admin host.");

    await adminProcess.WaitForExitAsync();
    return adminProcess.ExitCode;
}
catch (Exception ex)
{
    Console.Error.WriteLine(ex.Message);

    if (!adminProcess.HasExited)
        adminProcess.Kill(entireProcessTree: true);

    return 1;
}

static int GetAvailablePort()
{
    using var listener = new TcpListener(IPAddress.Loopback, 0);
    listener.Start();
    return ((IPEndPoint)listener.LocalEndpoint).Port;
}

static async Task WaitForPortAsync(string host, int port, Process process, TimeSpan timeout)
{
    var deadline = DateTimeOffset.UtcNow.Add(timeout);

    while (DateTimeOffset.UtcNow.CompareTo(deadline) <= 0)
    {
        if (process.HasExited)
            throw new InvalidOperationException("CSharpDB Admin exited before it started listening.");

        try
        {
            using var client = new TcpClient();
            using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
            await client.ConnectAsync(host, port, cts.Token);
            return;
        }
        catch
        {
            await Task.Delay(250);
        }
    }

    throw new TimeoutException($"CSharpDB Admin did not start on {host}:{port}.");
}

Why ASPNETCORE_URLS Matters

The launcher should control the URL instead of guessing which URL the web app selected. Setting ASPNETCORE_URLS tells ASP.NET Core exactly where to listen for this run.

The example uses 127.0.0.1 instead of a public interface. That keeps the Admin site local to the machine and avoids exposing the tool on the network by accident.

Opening the Default Browser

The browser launch is the one line that makes this feel like a desktop app:

Process.Start(new ProcessStartInfo
{
    FileName = homeUrl,
    UseShellExecute = true
});

UseShellExecute = true asks Windows to open the URL with the user's default handler, which is normally their default browser.

What to Ship

The launcher does not remove the need to ship the published Admin app. It just becomes the friendly entry point. The publish folder should look like this:

CSharpDB.Admin.Launcher.exe
CSharpDB.Admin.exe
appsettings.json
appsettings.Development.json
CSharpDB.Admin.staticwebassets.endpoints.json
wwwroot/
...

Users run CSharpDB.Admin.Launcher.exe. The launcher starts CSharpDB.Admin.exe, waits for the site, and opens the home page.

When to Put This in the Web App Instead

Adding browser launch code directly to the Admin web app is still reasonable if the published Admin executable is always meant to be used interactively on a desktop. It reduces the number of binaries and makes CSharpDB.Admin.exe itself the only entry point.

A separate launcher is better when you want the Admin project to remain a normal web host that can still be run by developers, scripts, services, or future packaging tools without always opening a browser.

The Result

With a small C# launcher, CSharpDB Admin keeps its ASP.NET Core architecture and gains a desktop-style startup path. The user double-clicks one launcher executable, the local site starts on an available localhost port, and the default browser opens to the Admin home page.