feat.airapp运行时修改

This commit is contained in:
lincube
2026-06-22 12:35:49 +08:00
parent 2ead9d8619
commit ef764ff974
7 changed files with 532 additions and 17 deletions

View File

@@ -1,3 +1,4 @@
using System.Threading.Tasks;
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Shell;
@@ -8,6 +9,9 @@ public static class Program
[STAThread]
public static async Task<int> Main(string[] args)
{
// 注册全局异常处理器,防止 async void / 未观察任务异常导致启动器崩溃并显示堆栈对话框
RegisterGlobalExceptionHandlers();
var commandContext = CommandContext.FromArgs(args);
var execution = LauncherExecutionContext.Capture();
Logger.Initialize();
@@ -78,4 +82,38 @@ public static class Program
.WithInterFont()
.LogToTrace();
}
/// <summary>
/// 注册全局异常处理器,避免 async void如 ErrorWindow 的复制按钮)抛出未捕获异常时
/// 触发 Windows 崩溃对话框显示堆栈跟踪,从而让用户看到无法理解的报错。
/// </summary>
private static void RegisterGlobalExceptionHandlers()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
try
{
var exception = eventArgs.ExceptionObject as Exception
?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception.");
Logger.Error($"Launcher unhandled exception. IsTerminating={eventArgs.IsTerminating}.", exception);
}
catch
{
// 全局处理器本身不能再抛出异常
}
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
try
{
Logger.Error("Launcher unobserved task exception.", eventArgs.Exception);
}
catch
{
// 忽略日志写入失败
}
eventArgs.SetObserved();
};
}
}

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.IO;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
@@ -424,6 +425,12 @@ internal static class LauncherGuiCoordinator
? parsedPid
: (int?)null;
// 读取主程序崩溃转储,获取真实崩溃原因
var crashDump = ReadLatestHostCrashDump();
// 读取主程序 stderr 输出(来自启动器捕获)
var stderrOutput = ExtractHostStderr(result);
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
@@ -438,8 +445,19 @@ internal static class LauncherGuiCoordinator
errorWindow.ConfigureForGenericFailure(allowRetry: true);
}
errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
var fullMessage = $"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}";
if (!string.IsNullOrWhiteSpace(stderrOutput))
{
fullMessage += $"\n\n--- Host Output ---\n{stderrOutput}";
}
if (!string.IsNullOrWhiteSpace(crashDump))
{
fullMessage += $"\n\n--- Host Crash Details ---\n{crashDump}";
}
errorWindow.SetErrorMessage(fullMessage);
errorWindow.Show();
}
catch (Exception ex)
@@ -464,6 +482,94 @@ internal static class LauncherGuiCoordinator
}
}
/// <summary>
/// 从 LauncherResult.Details 中提取主程序的 stderr 输出。
/// 启动器在 Direct 模式下会重定向主程序的 stderr 并存入 details。
/// </summary>
private static string? ExtractHostStderr(LauncherResult result)
{
// 优先使用 fallback 尝试的 stderr因为 fallback 通常是 ShellExecutestderr 不可用)
// 所以优先使用 firstAttemptStderr
var stderrKeys = new[] { "firstAttemptStderr", "fallbackAttemptStderr" };
foreach (var key in stderrKeys)
{
if (result.Details.TryGetValue(key, out var stderr) && !string.IsNullOrWhiteSpace(stderr))
{
// 限制长度避免错误窗口过长
var trimmed = stderr.Trim();
if (trimmed.Length > 2000)
{
trimmed = trimmed.Substring(0, 2000) + "\n... (truncated)";
}
return trimmed;
}
}
return null;
}
/// <summary>
/// 读取主程序最新的崩溃转储文件内容。
/// 主程序在崩溃时会写入 LocalApplicationData/LanMountainDesktop/crashes/ 目录。
/// 启动器读取最近 5 分钟内的崩溃转储,避免显示过时的崩溃信息。
/// </summary>
private static string? ReadLatestHostCrashDump()
{
try
{
var crashDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "crashes");
if (!Directory.Exists(crashDir))
{
return null;
}
// 优先读取 latest.txt 标记文件指向的崩溃转储
var latestMarker = Path.Combine(crashDir, "latest.txt");
string? targetCrashFile = null;
if (File.Exists(latestMarker))
{
var referencedPath = File.ReadAllText(latestMarker).Trim();
if (File.Exists(referencedPath))
{
var fileInfo = new FileInfo(referencedPath);
if (fileInfo.CreationTime > DateTime.Now.AddMinutes(-5))
{
targetCrashFile = referencedPath;
}
}
}
// 回退:查找最近 5 分钟内的崩溃转储文件
if (targetCrashFile is null)
{
var recentCrash = Directory.GetFiles(crashDir, "crash-*.txt")
.Select(f => new FileInfo(f))
.Where(f => f.CreationTime > DateTime.Now.AddMinutes(-5))
.OrderByDescending(f => f.CreationTime)
.FirstOrDefault();
targetCrashFile = recentCrash?.FullName;
}
if (string.IsNullOrWhiteSpace(targetCrashFile) || !File.Exists(targetCrashFile))
{
return null;
}
var content = File.ReadAllText(targetCrashFile);
Logger.Info($"Read host crash dump: {targetCrashFile}");
return content;
}
catch (Exception ex)
{
Logger.Warn($"Failed to read host crash dump: {ex.Message}");
return null;
}
}
private static async Task<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);

View File

@@ -19,11 +19,12 @@ internal sealed record HostStartAttempt(
string? FailureReason,
string? PackageRoot,
string? WorkingDirectory,
string? Arguments)
string? Arguments,
string? StderrOutput = null)
{
public int? ProcessId => Process?.Id;
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan) =>
public static HostStartAttempt Started(HostStartMode startMode, Process process, HostLaunchPlan plan, string? stderrOutput = null) =>
new(
startMode,
true,
@@ -33,9 +34,10 @@ internal sealed record HostStartAttempt(
null,
plan.PackageRoot,
plan.WorkingDirectory,
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments),
stderrOutput);
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan) =>
public static HostStartAttempt EarlyExit(HostStartMode startMode, Process process, int exitCode, HostLaunchPlan plan, string? stderrOutput = null) =>
new(
startMode,
true,
@@ -45,9 +47,10 @@ internal sealed record HostStartAttempt(
null,
plan.PackageRoot,
plan.WorkingDirectory,
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments),
stderrOutput);
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null) =>
public static HostStartAttempt StartFailed(HostStartMode startMode, string failureReason, HostLaunchPlan? plan = null, string? stderrOutput = null) =>
new(
startMode,
false,
@@ -57,7 +60,8 @@ internal sealed record HostStartAttempt(
failureReason,
plan?.PackageRoot,
plan?.WorkingDirectory,
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments));
plan is null ? null : HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments),
stderrOutput);
}
internal sealed record HostLaunchOutcome(

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Shell;
using LanMountainDesktop.Launcher.Views;
@@ -232,8 +233,13 @@ internal sealed class HostLaunchService
UseShellExecute = startMode == HostStartMode.ShellExecute
};
// Direct 模式下重定向 stderr捕获主程序崩溃时输出的诊断信息
var stderrBuilder = new StringBuilder();
if (startMode == HostStartMode.Direct)
{
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardOutput = false;
foreach (var argument in plan.Arguments)
{
startInfo.ArgumentList.Add(argument);
@@ -269,18 +275,37 @@ internal sealed class HostLaunchService
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
}
// Direct 模式下异步读取 stderr避免死锁并捕获主程序输出
if (startMode == HostStartMode.Direct)
{
process.ErrorDataReceived += (_, dataArgs) =>
{
if (!string.IsNullOrEmpty(dataArgs.Data))
{
stderrBuilder.AppendLine(dataArgs.Data);
Logger.Warn($"Host stderr: {dataArgs.Data}");
}
};
process.BeginErrorReadLine();
}
// 等待一小段时间,检查进程是否立即退出
await Task.Delay(500).ConfigureAwait(false);
if (process.HasExited)
{
var stderrOutput = stderrBuilder.ToString();
Logger.Error($"CRITICAL: Host process exited immediately! ExitCode={process.ExitCode}; Path='{plan.HostPath}'");
Console.Error.WriteLine($"[CRITICAL] Host process exited immediately with code {process.ExitCode}");
return HostStartAttempt.StartFailed(startMode, $"process_exited_immediately_code_{process.ExitCode}", plan);
if (!string.IsNullOrWhiteSpace(stderrOutput))
{
Logger.Error($"Host stderr captured before exit:\n{stderrOutput}");
}
return HostStartAttempt.StartFailed(startMode, $"process_exited_immediately_code_{process.ExitCode}", plan, stderrOutput);
}
Logger.Info($"Host process started successfully and is running. PID={process.Id}");
return HostStartAttempt.Started(startMode, process, plan);
return HostStartAttempt.Started(startMode, process, plan, stderrBuilder.ToString());
}
catch (Exception ex)
{
@@ -288,7 +313,7 @@ internal sealed class HostLaunchService
Console.Error.WriteLine($"[CRITICAL] Host start failed: {ex.Message}");
Console.Error.WriteLine($"[CRITICAL] Path: {plan.HostPath}");
Console.Error.WriteLine($"[CRITICAL] Exception: {ex}");
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan, stderrBuilder.ToString());
}
}
@@ -319,6 +344,10 @@ internal sealed class HostLaunchService
details["arguments"] = firstAttempt.Arguments ?? string.Empty;
details["firstAttemptFailureReason"] = firstAttempt.FailureReason ?? string.Empty;
details["firstAttemptExitCode"] = firstAttempt.ExitCode?.ToString() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(firstAttempt.StderrOutput))
{
details["firstAttemptStderr"] = firstAttempt.StderrOutput;
}
}
if (secondAttempt is not null)
@@ -331,6 +360,10 @@ internal sealed class HostLaunchService
details["fallbackArguments"] = secondAttempt.Arguments ?? string.Empty;
details["fallbackFailureReason"] = secondAttempt.FailureReason ?? string.Empty;
details["fallbackExitCode"] = secondAttempt.ExitCode?.ToString() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(secondAttempt.StderrOutput))
{
details["fallbackAttemptStderr"] = secondAttempt.StderrOutput;
}
}
return details;

View File

@@ -1,9 +1,12 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Launcher.Resources;
using LanMountainDesktop.Launcher.Infrastructure;
@@ -235,6 +238,27 @@ public partial class ErrorWindow : Window
{
try
{
// 优先打开主程序崩溃转储目录(最有诊断价值)
var crashDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "crashes");
if (Directory.Exists(crashDir) && Directory.GetFiles(crashDir, "crash-*.txt").Length > 0)
{
OpenPath(crashDir);
return;
}
// 其次打开主程序日志目录
var hostLogDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "log");
if (Directory.Exists(hostLogDir) && Directory.GetFiles(hostLogDir).Length > 0)
{
OpenPath(hostLogDir);
return;
}
// 回退到启动器日志文件
var logFilePath = Logger.GetLogFilePath();
if (!string.IsNullOrWhiteSpace(logFilePath) && File.Exists(logFilePath))
{
@@ -251,6 +275,7 @@ public partial class ErrorWindow : Window
return;
}
// 最后回退到配置目录
var configDirectory = GetConfigBaseDirectory();
if (Directory.Exists(configDirectory))
{
@@ -259,7 +284,7 @@ public partial class ErrorWindow : Window
}
catch (Exception ex)
{
Debug.WriteLine($"[ErrorWindow] Failed to open log path: {ex}");
Logger.Warn($"Failed to open log path: {ex.Message}");
}
await Task.CompletedTask;
@@ -267,23 +292,105 @@ public partial class ErrorWindow : Window
private async void OnCopyDetailsClick(object? sender, RoutedEventArgs e)
{
var button = sender as Button;
var originalContent = button?.Content;
try
{
var details = this.FindControl<TextBox>("ErrorDetailsTextBox")?.Text;
var details = GetDetailsText();
if (string.IsNullOrWhiteSpace(details))
{
details = this.FindControl<TextBlock>("ErrorMessageText")?.Text;
ShowCopyFeedback(button, originalContent, false, "No content to copy");
return;
}
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is not null && !string.IsNullOrWhiteSpace(details))
if (clipboard is null)
{
// 剪贴板不可用(窗口未激活/会话限制),写入临时文件作为兜底
var filePath = await WriteToTempFileAsync(details).ConfigureAwait(true);
ShowCopyFeedback(button, originalContent, true, $"Saved to {Path.GetFileName(filePath)}");
return;
}
try
{
await clipboard.SetTextAsync(details);
ShowCopyFeedback(button, originalContent, true, "Copied");
}
catch (Exception clipboardEx)
{
// 剪贴板服务异常(组策略/RDP 限制等),写入临时文件兜底
Logger.Warn($"Clipboard SetTextAsync failed: {clipboardEx.Message}. Falling back to temp file.");
var filePath = await WriteToTempFileAsync(details).ConfigureAwait(true);
ShowCopyFeedback(button, originalContent, true, $"Saved to {Path.GetFileName(filePath)}");
}
}
catch (Exception ex)
{
Debug.WriteLine($"[ErrorWindow] Failed to copy diagnostics: {ex}");
Logger.Error("Failed to copy diagnostics.", ex);
try
{
var details = GetDetailsText();
if (!string.IsNullOrWhiteSpace(details))
{
await WriteToTempFileAsync(details).ConfigureAwait(true);
}
}
catch (Exception fallbackEx)
{
Logger.Warn($"Temp file fallback also failed: {fallbackEx.Message}");
}
ShowCopyFeedback(button, originalContent, false, "Copy failed");
}
}
private string GetDetailsText()
{
var details = this.FindControl<TextBox>("ErrorDetailsTextBox")?.Text;
if (string.IsNullOrWhiteSpace(details))
{
details = this.FindControl<TextBlock>("ErrorMessageText")?.Text;
}
return details ?? string.Empty;
}
private static async Task<string> WriteToTempFileAsync(string content)
{
var tempDir = Path.GetTempPath();
var fileName = $"LanDesktopError_{DateTime.Now:yyyyMMdd_HHmmss}.txt";
var path = Path.Combine(tempDir, fileName);
await File.WriteAllTextAsync(path, content, Encoding.UTF8).ConfigureAwait(false);
Logger.Info($"Error details written to temp file: {path}");
return path;
}
private void ShowCopyFeedback(Button? button, object? originalContent, bool success, string message)
{
if (button is null)
{
return;
}
try
{
button.Content = message;
button.IsEnabled = false;
DispatcherTimer.RunOnce(() =>
{
try
{
button.Content = originalContent;
button.IsEnabled = true;
}
catch
{
// 窗口可能已关闭,忽略
}
}, TimeSpan.FromSeconds(2));
}
catch
{
// 忽略反馈失败
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -1861,11 +1862,17 @@ public partial class App : Application
AppLogger.Info(
"PublicIpc",
$"Public IPC host started successfully. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
// IPC 初始化成功后清除失败标记文件
TryClearIpcFailureMarker();
}
catch (Exception ex)
{
AppLogger.Error("PublicIpc", "CRITICAL: Failed to initialize public IPC host. Launcher will not be able to connect to this process.", ex);
// 写入 IPC 失败标记文件,启动器可检测该文件感知 IPC 失败
TryWriteIpcFailureMarker(ex);
// 尝试通过标准错误输出告知启动器
try
{
@@ -1879,6 +1886,54 @@ public partial class App : Application
}
}
/// <summary>
/// 写入 IPC 初始化失败标记文件,供启动器检测主程序 IPC 不可用的情况。
/// </summary>
private static void TryWriteIpcFailureMarker(Exception ex)
{
try
{
var markerDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "diagnostics");
Directory.CreateDirectory(markerDir);
var markerPath = Path.Combine(markerDir, ".ipc-init-failed");
var content = $"{DateTime.Now:O}\nPID={Environment.ProcessId}\nException={ex.GetType().FullName}\nMessage={ex.Message}\nStackTrace={ex.StackTrace}";
File.WriteAllText(markerPath, content, System.Text.Encoding.UTF8);
AppLogger.Info("PublicIpc", $"IPC failure marker written to {markerPath}");
}
catch (Exception markerEx)
{
try
{
AppLogger.Warn("PublicIpc", "Failed to write IPC failure marker.", markerEx);
}
catch { /* best effort */ }
}
}
/// <summary>
/// IPC 初始化成功后清除失败标记文件。
/// </summary>
private static void TryClearIpcFailureMarker()
{
try
{
var markerPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "diagnostics", ".ipc-init-failed");
if (File.Exists(markerPath))
{
File.Delete(markerPath);
AppLogger.Info("PublicIpc", "Cleared stale IPC failure marker.");
}
}
catch
{
// 清除失败不影响主流程
}
}
private IReadOnlyList<PublicPluginDescriptor> BuildPublicPluginDescriptors()
{
var runtime = _pluginRuntimeService;

View File

@@ -1,4 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using LanMountainDesktop.DesktopHost;
@@ -13,6 +16,9 @@ public sealed class Program
{
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
private const string DisablePatchersEnvVar = "LAN_DESKTOP_DISABLE_PATCHERS";
private const string DisableRenderRetryEnvVar = "LAN_DESKTOP_DISABLE_RENDER_RETRY";
[STAThread]
public static void Main(string[] args)
{
@@ -30,10 +36,12 @@ public sealed class Program
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
var attemptedRenderModes = new List<string>();
try
{
var renderMode = LoadConfiguredRenderMode();
StartupRenderMode = renderMode;
attemptedRenderModes.Add(renderMode);
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
LoadChromePatchState();
InstallChromePatchersIfNeeded();
@@ -43,6 +51,29 @@ public sealed class Program
catch (Exception ex)
{
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
WriteCrashDump(ex, StartupRenderMode);
// 渲染模式安全降级:若失败且未禁用重试,且当前不是软件渲染,则用软件渲染重试一次
if (ShouldRetryWithSoftwareRendering(StartupRenderMode, ex) &&
!attemptedRenderModes.Contains(AppRenderingModeHelper.Software))
{
AppLogger.Warn("Startup", $"Retrying startup with Software rendering mode (previous='{StartupRenderMode}').");
StartupRenderMode = AppRenderingModeHelper.Software;
try
{
BuildAvaloniaApp(AppRenderingModeHelper.Software)
.StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally after Software render retry.");
return;
}
catch (Exception retryEx)
{
AppLogger.Critical("Startup", "Software render retry also failed.", retryEx);
WriteCrashDump(retryEx, AppRenderingModeHelper.Software);
throw;
}
}
throw;
}
}
@@ -129,6 +160,14 @@ public sealed class Program
private static void InstallChromePatchersIfNeeded()
{
// 紧急关闭开关:设置环境变量 LAN_DESKTOP_DISABLE_PATCHERS=1 可跳过所有 patcher 安装
// 用于诊断 patcher 导致启动崩溃的问题
if (Environment.GetEnvironmentVariable(DisablePatchersEnvVar) == "1")
{
AppLogger.Warn("Startup", $"Chrome patchers skipped by environment variable '{DisablePatchersEnvVar}=1'.");
return;
}
if (!OperatingSystem.IsWindows())
{
return;
@@ -152,6 +191,133 @@ public sealed class Program
}
}
/// <summary>
/// 判断是否应该用软件渲染重试。当异常看起来与渲染相关GPU/驱动/平台初始化),
/// 且当前渲染模式不是软件渲染,且未通过环境变量禁用重试时返回 true。
/// </summary>
private static bool ShouldRetryWithSoftwareRendering(string currentRenderMode, Exception ex)
{
if (string.Equals(currentRenderMode, AppRenderingModeHelper.Software, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (Environment.GetEnvironmentVariable(DisableRenderRetryEnvVar) == "1")
{
return false;
}
// 渲染相关异常的特征关键字(覆盖 Avalonia Compositor / GPU / Vulkan / Wgl / Angle 等)
var message = (ex.Message ?? string.Empty) + " " + (ex.GetType().FullName ?? string.Empty);
var renderedKeywords = new[]
{
"render", "gpu", "vulkan", "wgl", "angle", "egl", "opengl",
"compositor", "surface", "swapchain", "driver", "directx",
"Win32PlatformOptions", "RenderingMode"
};
foreach (var keyword in renderedKeywords)
{
if (message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
// Avalonia 初始化阶段抛出的 TypeLoadException / InvalidOperationException 也尝试降级
if (ex is InvalidOperationException or TypeLoadException or TypeInitializationException)
{
return true;
}
return false;
}
/// <summary>
/// 将崩溃信息写入崩溃转储文件,供启动器读取并展示给用户。
/// 文件位于 LocalApplicationData/LanMountainDesktop/crashes/ 目录下。
/// </summary>
private static void WriteCrashDump(Exception ex, string renderMode)
{
try
{
var crashDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop", "crashes");
Directory.CreateDirectory(crashDir);
var crashFile = Path.Combine(crashDir, $"crash-{DateTime.Now:yyyyMMdd_HHmmss}.txt");
var sb = new StringBuilder();
sb.AppendLine($"Time: {DateTime.Now:O}");
sb.AppendLine($"RenderMode: {renderMode}");
sb.AppendLine($"ProcessId: {Environment.ProcessId}");
sb.AppendLine($"OS: {Environment.OSVersion}");
sb.AppendLine($"OSArchitecture: {System.Runtime.InteropServices.RuntimeInformation.OSArchitecture}");
sb.AppendLine($".NET: {Environment.Version}");
sb.AppendLine($"WorkingDirectory: {Environment.CurrentDirectory}");
sb.AppendLine($"BaseDirectory: {AppContext.BaseDirectory}");
sb.AppendLine();
sb.AppendLine($"Exception Type: {ex.GetType().FullName}");
sb.AppendLine($"Exception Message: {ex.Message}");
sb.AppendLine();
sb.AppendLine("Stack Trace:");
sb.AppendLine(ex.StackTrace ?? "<no stack trace>");
sb.AppendLine();
if (ex.InnerException is not null)
{
sb.AppendLine("Inner Exception:");
sb.AppendLine($" Type: {ex.InnerException.GetType().FullName}");
sb.AppendLine($" Message: {ex.InnerException.Message}");
sb.AppendLine($" Stack Trace:");
sb.AppendLine(ex.InnerException.StackTrace ?? "<no stack trace>");
}
// 保留最近的崩溃转储(最多 10 个),避免无限增长
CleanupOldCrashDumps(crashDir);
File.WriteAllText(crashFile, sb.ToString(), System.Text.Encoding.UTF8);
AppLogger.Info("Startup", $"Crash dump written to {crashFile}");
// 同时写入一个 latest 标记文件,方便启动器快速定位
var latestMarker = Path.Combine(crashDir, "latest.txt");
File.WriteAllText(latestMarker, crashFile, System.Text.Encoding.UTF8);
}
catch (Exception dumpEx)
{
try
{
AppLogger.Warn("Startup", "Failed to write crash dump.", dumpEx);
}
catch
{
// best effort
}
}
}
private static void CleanupOldCrashDumps(string crashDir)
{
try
{
var files = Directory.GetFiles(crashDir, "crash-*.txt")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.CreationTime)
.Skip(10)
.ToArray();
foreach (var file in files)
{
try { file.Delete(); }
catch { /* 忽略单个文件删除失败 */ }
}
}
catch
{
// 清理失败不影响主流程
}
}
private static void RegisterGlobalExceptionLogging()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
@@ -164,6 +330,12 @@ public sealed class Program
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
exception);
// 运行时未处理异常也写入崩溃转储,供启动器读取
if (eventArgs.IsTerminating)
{
WriteCrashDump(exception, StartupRenderMode);
}
try
{
TelemetryServices.Crash?.CaptureUnhandledException(