fix(launcher): co-deploy acdream-bake on Build and write a crash report (#398)

Two gaps found launching the launcher for the LA11 gate.

1. acdream-bake was co-deployed only AfterTargets=Publish with a RID, so a
   plain `dotnet build` left the launcher with no bake tool beside it while
   App.OnFrameworkInitializationCompleted resolves it at
   AppContext.BaseDirectory/acdream-bake[.exe]. A developer-built launcher
   therefore reached the first-run wizard with an installer it could never
   run. CoDeployBakeToolToBuildOutput does for Build what the publish target
   does for Publish: still NO Launcher -> Bake project reference, still a
   self-contained single file so exactly one file lands beside the launcher
   rather than scattering Content/Chorizite assemblies into its output.
   Staged through obj/ because publishing straight into the launcher output
   makes the inner publish delete what the outer build just wrote.
   Inputs/Outputs keep it incremental - verified: 79.6 MB bake exe present,
   --help exits 0, and a second build skips the republish in ~1 s.

2. #398: the top-level guard printed only ex.Message, so the crash that
   preceded this commit surfaced with no file, line, or frame. The full
   exception now goes to a crash-reports file under the resolved data root
   and stderr names the path. The first implementation wrote to the machine
   real data root when option parsing itself failed, which broke LA11 process
   local roots during an isolated run; the reporter now reads --data-dir
   positionally for that fallback. Verified: report lands inside the isolated
   root and the real root stays empty.

The redaction comment states exactly what is guaranteed - args/environment
are never serialized, while exception text may quote an option name or path,
which is safe only because credentials never enter launcher state.

Launcher.Core 317/317 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 07:42:29 +02:00
parent ef9d610459
commit e1e94697b2
2 changed files with 144 additions and 0 deletions

View file

@ -12,8 +12,26 @@
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
<!-- App.OnFrameworkInitializationCompleted resolves the bake CLI at
AppContext.BaseDirectory/acdream-bake[.exe]. Publish co-deploys it
(PublishCoDeployedBakeTool below), but an ordinary `dotnet build`
did not, so a developer-built launcher reached the first-run wizard
with no bake tool beside it. This does for Build what that target
does for Publish. Set to false to skip (CI publish lanes pass
PublishBakeTool=false into the inner build for the same reason). -->
<CoDeployBakeToolOnBuild Condition="'$(CoDeployBakeToolOnBuild)' == ''">true</CoDeployBakeToolOnBuild>
<_BakeExecutableName Condition="$([MSBuild]::IsOSPlatform('Windows'))">acdream-bake.exe</_BakeExecutableName>
<_BakeExecutableName Condition="'$(_BakeExecutableName)' == ''">acdream-bake</_BakeExecutableName>
</PropertyGroup>
<!-- Declared outside the target so the Inputs/Outputs check below can see
them: MSBuild evaluates a target's Inputs before the target body runs. -->
<ItemGroup>
<_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\**\*.cs"
Exclude="$(MSBuildProjectDirectory)\..\AcDream.Bake\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Bake\obj\**\*.cs" />
<_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
@ -28,6 +46,38 @@
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
</ItemGroup>
<!-- Build-time counterpart of PublishCoDeployedBakeTool. Same rule applies:
NO Launcher -> Bake project reference. The tool is published as its own
self-contained single file so exactly one file lands beside the
launcher, matching the shipped layout - a framework-dependent copy would
scatter AcDream.Content/Chorizite assemblies into the launcher's output
and risk colliding with its own. Inputs/Outputs keep incremental builds
from re-publishing it every time. -->
<Target Name="CoDeployBakeToolToBuildOutput"
AfterTargets="Build"
Condition="'$(CoDeployBakeToolOnBuild)' == 'true' and '$(PublishBakeTool)' == 'true' and '$(DesignTimeBuild)' != 'true'"
Inputs="@(_BakeToolSource)"
Outputs="$(OutputPath)$(_BakeExecutableName)">
<PropertyGroup>
<_BakeBuildRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_BakeBuildRid>
<_BakeBuildRid Condition="'$(_BakeBuildRid)' == ''">$(NETCoreSdkPortableRuntimeIdentifier)</_BakeBuildRid>
<_BakeBuildStagingDirectory>$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)bake-codeploy\$(Configuration)\$(_BakeBuildRid)\</_BakeBuildStagingDirectory>
<_BakeBuildOutputDirectory Condition="$([System.IO.Path]::IsPathRooted('$(OutputPath)'))">$(OutputPath)</_BakeBuildOutputDirectory>
<_BakeBuildOutputDirectory Condition="'$(_BakeBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath)</_BakeBuildOutputDirectory>
</PropertyGroup>
<!-- Staged, then copied: publishing straight into the launcher's output
would have the inner publish delete files the outer build just wrote. -->
<MSBuild Projects="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj"
Targets="Restore;Publish"
BuildInParallel="false"
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(_BakeBuildRid);SelfContained=true;PublishSingleFile=true;IncludeNativeLibrariesForSelfExtract=true;EnableSingleFileAnalyzer=false;PublishDir=$(_BakeBuildStagingDirectory);PublishBakeTool=false;CoDeployBakeToolOnBuild=false" />
<Copy SourceFiles="$(_BakeBuildStagingDirectory)$(_BakeExecutableName)"
DestinationFolder="$(_BakeBuildOutputDirectory)"
SkipUnchangedFiles="true" />
<Message Importance="high"
Text="Co-deployed $(_BakeExecutableName) ($(_BakeBuildRid)) next to the launcher." />
</Target>
<!-- Distribution composition only: do not add a Launcher -> Bake project
reference. A per-RID launcher publish explicitly publishes the GL-free
CLI as its own self-contained single file into the same directory. -->

View file

@ -1,4 +1,5 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
using Avalonia;
namespace AcDream.Launcher;
@ -42,10 +43,103 @@ internal static class Program
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. This method writes only the
/// exception chain plus non-identifying host facts; it never serializes
/// <paramref name="args"/>, the environment, or process state. It does NOT
/// claim the text is value-free: an exception message may quote whatever
/// the thrower put in it, including an offending option name or a path
/// (observed: "Launcher option '--x' requires a value"). That is acceptable
/// because a credential cannot reach this text by construction — the
/// launcher never holds a password in any field, credentials go straight to
/// a child process's stdin, and <c>LauncherProcessSpec</c> carries no
/// credential member (guarded by its own test). If that ever changes, this
/// sink needs the same credential scanning the status stream has.</para>
///
/// <para>Never throws: a crash reporter that can itself fail would replace
/// the original failure with its own.</para>
/// </summary>
private 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.
/// </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]))
{
return args[index + 1];
}
}
return null;
}
internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options)
{
ArgumentNullException.ThrowIfNull(options);