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
{
// 忽略反馈失败
}
}