From ef764ff974ff67e6283ca846cacdfd858b9f7f68 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 22 Jun 2026 12:35:49 +0800 Subject: [PATCH] =?UTF-8?q?feat.airapp=E8=BF=90=E8=A1=8C=E6=97=B6=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LanMountainDesktop.Launcher/Program.cs | 38 ++++ .../Shell/LauncherGuiCoordinator.cs | 110 ++++++++++- .../Startup/HostLaunchModels.cs | 18 +- .../Startup/HostLaunchService.cs | 39 +++- .../Views/ErrorWindow.axaml.cs | 117 +++++++++++- LanMountainDesktop/App.axaml.cs | 55 ++++++ LanMountainDesktop/Program.cs | 172 ++++++++++++++++++ 7 files changed, 532 insertions(+), 17 deletions(-) diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs index 426e17f..60c56a0 100644 --- a/LanMountainDesktop.Launcher/Program.cs +++ b/LanMountainDesktop.Launcher/Program.cs @@ -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 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(); } + + /// + /// 注册全局异常处理器,避免 async void(如 ErrorWindow 的复制按钮)抛出未捕获异常时 + /// 触发 Windows 崩溃对话框显示堆栈跟踪,从而让用户看到无法理解的报错。 + /// + 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(); + }; + } } diff --git a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs index e8f6200..280e0be 100644 --- a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs +++ b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs @@ -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 } } + /// + /// 从 LauncherResult.Details 中提取主程序的 stderr 输出。 + /// 启动器在 Direct 模式下会重定向主程序的 stderr 并存入 details。 + /// + private static string? ExtractHostStderr(LauncherResult result) + { + // 优先使用 fallback 尝试的 stderr(因为 fallback 通常是 ShellExecute,stderr 不可用) + // 所以优先使用 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; + } + + /// + /// 读取主程序最新的崩溃转储文件内容。 + /// 主程序在崩溃时会写入 LocalApplicationData/LanMountainDesktop/crashes/ 目录。 + /// 启动器读取最近 5 分钟内的崩溃转储,避免显示过时的崩溃信息。 + /// + 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 TryActivateExistingInstanceAsync() { var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs index 841cc56..22c37e4 100644 --- a/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs +++ b/LanMountainDesktop.Launcher/Startup/HostLaunchModels.cs @@ -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( diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs index 9fdc9d0..5d5b1c9 100644 --- a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs +++ b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs @@ -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; diff --git a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs index d4a4b6f..16dd145 100644 --- a/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs @@ -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("ErrorDetailsTextBox")?.Text; + var details = GetDetailsText(); if (string.IsNullOrWhiteSpace(details)) { - details = this.FindControl("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("ErrorDetailsTextBox")?.Text; + if (string.IsNullOrWhiteSpace(details)) + { + details = this.FindControl("ErrorMessageText")?.Text; + } + return details ?? string.Empty; + } + + private static async Task 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 + { + // 忽略反馈失败 } } diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 595b0ce..90043c3 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -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 } } + /// + /// 写入 IPC 初始化失败标记文件,供启动器检测主程序 IPC 不可用的情况。 + /// + 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 */ } + } + } + + /// + /// IPC 初始化成功后清除失败标记文件。 + /// + 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 BuildPublicPluginDescriptors() { var runtime = _pluginRuntimeService; diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 316c9c0..63840d8 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -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(); 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 } } + /// + /// 判断是否应该用软件渲染重试。当异常看起来与渲染相关(GPU/驱动/平台初始化), + /// 且当前渲染模式不是软件渲染,且未通过环境变量禁用重试时返回 true。 + /// + 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; + } + + /// + /// 将崩溃信息写入崩溃转储文件,供启动器读取并展示给用户。 + /// 文件位于 LocalApplicationData/LanMountainDesktop/crashes/ 目录下。 + /// + 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 ?? ""); + 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 ?? ""); + } + + // 保留最近的崩溃转储(最多 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(