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);