From e1e94697b27e6b506111f54d9a7ae0dc4c284cfc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:42:29 +0200 Subject: [PATCH] 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 --- src/AcDream.Launcher/AcDream.Launcher.csproj | 50 +++++++++++ src/AcDream.Launcher/Program.cs | 94 ++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 350260b2..01a0dfbc 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -12,8 +12,26 @@ true true true + + true + <_BakeExecutableName Condition="$([MSBuild]::IsOSPlatform('Windows'))">acdream-bake.exe + <_BakeExecutableName Condition="'$(_BakeExecutableName)' == ''">acdream-bake + + + <_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" /> + + @@ -28,6 +46,38 @@ + + + + <_BakeBuildRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + <_BakeBuildRid Condition="'$(_BakeBuildRid)' == ''">$(NETCoreSdkPortableRuntimeIdentifier) + <_BakeBuildStagingDirectory>$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)bake-codeploy\$(Configuration)\$(_BakeBuildRid)\ + <_BakeBuildOutputDirectory Condition="$([System.IO.Path]::IsPathRooted('$(OutputPath)'))">$(OutputPath) + <_BakeBuildOutputDirectory Condition="'$(_BakeBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath) + + + + + + + diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs index bb6b7c1f..982aad60 100644 --- a/src/AcDream.Launcher/Program.cs +++ b/src/AcDream.Launcher/Program.cs @@ -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; } } + /// + /// Issue #398: stderr alone carried only ex.Message, 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. + /// + /// Redaction contract, stated exactly. This method writes only the + /// exception chain plus non-identifying host facts; it never serializes + /// , 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 LauncherProcessSpec carries no + /// credential member (guarded by its own test). If that ever changes, this + /// sink needs the same credential scanning the status stream has. + /// + /// Never throws: a crash reporter that can itself fail would replace + /// the original failure with its own. + /// + 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; + } + } + + /// + /// Positional, validation-free read of --data-dir 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 — + /// remains the only validated path authority. + /// + 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);