mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 01:44:26 +08:00
feat.airapp运行时修改
This commit is contained in:
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 通常是 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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// 忽略反馈失败
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user