acdream/src/AcDream.Launcher/Program.cs
Erik 981e168fb9 fix(launcher): Campaign LA gate-round-1 review findings F1-F6 + hardening
F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.

F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.

F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.

F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.

Launcher 67/67, Launcher.Core 317/317.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:19:23 +02:00

174 lines
7.1 KiB
C#

using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
using Avalonia;
namespace AcDream.Launcher;
internal static class Program
{
[STAThread]
public static int Main(string[] args)
{
try
{
LauncherStartupOptions options = LauncherStartupOptions.Parse(args);
if (options.Mode == LauncherStartupMode.VerifyPublish)
{
// A display-free execution probe for the packaged artifact.
// Parsing above deliberately never resolves user paths.
return 0;
}
using var httpClient = new HttpClient();
var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient);
string executable = Environment.ProcessPath
?? throw new InvalidOperationException(
"The launcher executable path is unavailable.");
SelfUpdateStartupResult startup = LauncherSelfUpdateBootstrap.HandleAsync(
args,
selfUpdates,
AppContext.BaseDirectory,
executable)
.GetAwaiter()
.GetResult();
if (startup.ShouldExit)
{
return startup.ExitCode;
}
RequireUnchangedPublicArguments(options, startup);
return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Launcher startup failed safely: {ex.Message}");
string? report = TryWriteCrashReport(args, ex);
Console.Error.WriteLine(report is null
? "No crash report could be written."
: $"Crash report: {report}");
return 74;
}
}
/// <summary>
/// Issue #398: stderr alone carried only <c>ex.Message</c>, so a fatal
/// dispatcher exception reached the operator with no file, line, or frame
/// and diagnosis required editing this guard and rebuilding. The full
/// exception goes to a file under the resolved data root instead of to
/// stderr, and the path is printed.
///
/// <para>Redaction contract, stated exactly (corrected by the gate-round-1
/// review, F1). This method writes only the exception chain plus
/// non-identifying host facts; it never serializes <paramref name="args"/>,
/// the environment, or process state. Exception TEXT may quote whatever
/// the thrower put in it — an option name, a path (observed: "Launcher
/// option '--x' requires a value"). The launcher DOES hold credentials:
/// <c>ProfileEditorDialogViewModel</c>'s password field,
/// <c>AccountProfile.Password</c> (plaintext by user decision), and
/// <c>StartRequest.Password</c>. The invariant this sink actually rests on
/// is narrower: NO code path interpolates a credential VALUE into an
/// exception message (System.Text.Json failures quote the JSON path, not
/// the value; option/path errors quote the flag, not file contents).
/// <c>MainWindowViewTests</c> pins it with a forced-failure test asserting
/// a stored password never appears in the crash file. Any new throw site
/// that puts profile or request state into a message breaks that test —
/// at which point this sink needs the status-stream's credential
/// scanning, not a bigger comment.</para>
///
/// <para>Never throws: a crash reporter that can itself fail would replace
/// the original failure with its own.</para>
/// </summary>
internal static string? TryWriteCrashReport(string[] args, Exception failure)
{
try
{
string dataDirectory;
try
{
dataDirectory = LauncherStartupOptions.Parse(args).Paths.DataDirectory;
}
catch
{
// Parsing is one of the things that can fail here, and the
// caller's --data-dir must still be honored: LA11's roots are
// process-local, so a crash report written to the machine's
// real data root during an isolated run would break that
// isolation (observed doing exactly that before this branch
// existed). Read the root positionally without validating it,
// and only fall back to the defaults when it is absent.
dataDirectory = TryReadRequestedDataDirectory(args)
?? ApplicationPathSet.Resolve().DataDirectory;
}
string directory = Path.Combine(dataDirectory, "crash-reports");
Directory.CreateDirectory(directory);
string path = Path.Combine(
directory,
$"launcher-crash-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.log");
File.WriteAllText(
path,
$"""
acdream launcher crash report
utc: {DateTime.UtcNow:O}
os: {Environment.OSVersion}
rid: {System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier}
version: {typeof(Program).Assembly.GetName().Version}
{failure}
""");
return path;
}
catch
{
return null;
}
}
/// <summary>
/// Positional, validation-free read of <c>--data-dir</c> for the crash
/// reporter only, so an isolated run keeps its evidence inside its own
/// roots even when option parsing is what failed. Never used for anything
/// the launcher actually runs on — <see cref="LauncherStartupOptions"/>
/// remains the only validated path authority. Fully-qualified paths only
/// (gate-round-1 review): a relative or flag-shaped value would create
/// <c>./&lt;value&gt;/crash-reports</c> wherever the CWD happens to be,
/// which defeats the isolation this fallback exists to preserve.
/// </summary>
private static string? TryReadRequestedDataDirectory(string[] args)
{
for (int index = 0; index + 1 < args.Length; index++)
{
if (string.Equals(args[index], "--data-dir", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(args[index + 1])
&& Path.IsPathFullyQualified(args[index + 1]))
{
return args[index + 1];
}
}
return null;
}
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return AppBuilder.Configure(() => new App(options))
.UsePlatformDetect();
}
internal static void RequireUnchangedPublicArguments(
LauncherStartupOptions options,
SelfUpdateStartupResult startup)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(startup);
if (!startup.RemainingArguments.SequenceEqual(
options.PublicArguments,
StringComparer.Ordinal))
{
throw new InvalidOperationException(
"The self-update bootstrap changed validated launcher arguments.");
}
}
}