mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-07-01 15: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
|
||||
{
|
||||
// 忽略反馈失败
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user