diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index ae616ba86..eab102a51 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -543,6 +543,11 @@ partial class HideConfigGroup /// [ConfigItem("LaunchAdvanceDisableCrashAnalysis", false, ConfigSource.Local)] public partial bool DisableCrashAnalysis { get; set; } + /// + /// 允许将错误报告收起为角落入口,并在独立窗口中重新打开。 + /// + [ConfigItem("LaunchAdvanceCrashReportDock", true, ConfigSource.Local)] public partial bool CrashReportDock { get; set; } + /// /// 渲染器。 /// diff --git a/PCL.Core/App/Localization/Languages/en-GB.xaml b/PCL.Core/App/Localization/Languages/en-GB.xaml index a7a7a39e4..7055945ea 100644 --- a/PCL.Core/App/Localization/Languages/en-GB.xaml +++ b/PCL.Core/App/Localization/Languages/en-GB.xaml @@ -111,11 +111,17 @@ Crash analysis failed. See the details below. + Collapse Export crash report Modify settings + Open in a separate window View log Minecraft crashed Crash report analysis result + Discard this crash report + {0}-{1} + Click to view this crash report in a separate window, so you can keep it at hand while fixing the problem. + Crash report collapsed. You can reopen it from the entry at the bottom-right corner. Copy details The crash report could not be exported. Target path: {0} The crash report could not be exported, and the target path is unavailable. @@ -196,6 +202,8 @@ Crash Report-{0}.zip Minecraft crash report (*.zip)|*.zip Select save location + Copy report + Crash report @@ -2732,6 +2740,8 @@ Disable automatic crash analysis + Collapse crash reports and view them separately + When enabled, the crash report can be collapsed into an entry at the bottom-right corner and reopened in a separate window at any time, so you can keep it at hand while fixing mod conflicts. When enabled, the crash analysis page will no longer automatically appear when the game crashes. Disable Java Launch Wrapper Do not wrap with Java Wrapper when launching the game. Java Wrapper has been forcibly disabled because the system is ARM64 architecture. diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 43250bafb..a4d7757ed 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -111,11 +111,17 @@ Crash analysis failed. See the details below. + Collapse Export crash report Modify settings + Open in a separate window View log Minecraft crashed Crash report analysis result + Discard this crash report + {0}-{1} + Click to view this crash report in a separate window, so you can keep it at hand while fixing the problem. + Crash report collapsed. You can reopen it from the entry at the bottom-right corner. Copy details The crash report could not be exported. Target path: {0} The crash report could not be exported, and the target path is unavailable. @@ -196,6 +202,8 @@ Crash Report-{0}.zip Minecraft crash report (*.zip)|*.zip Select save location + Copy report + Crash report @@ -2732,6 +2740,8 @@ Disable automatic crash analysis + Collapse crash reports and view them separately + When enabled, the crash report can be collapsed into an entry at the bottom-right corner and reopened in a separate window at any time, so you can keep it at hand while fixing mod conflicts. When enabled, the crash analysis page will no longer automatically appear when the game crashes. Disable Java Launch Wrapper Do not wrap with Java Wrapper when launching the game. Java Wrapper has been forcibly disabled because the system is ARM64 architecture. diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index 4834027e9..6c9d4b2d3 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -111,11 +111,17 @@ 崩溃分析失败。请查看错误详情。 + 收起 导出错误报告 前往修改 + 在独立窗口中打开 查看日志 Minecraft 出现错误 错误报告分析结果 + 丢弃这份错误报告 + {0}-{1} + 点击可在独立窗口中查看这次游戏崩溃的错误报告,便于一边处理问题一边对照。 + 错误报告已收起,可在右下角入口中重新打开。 复制详细信息 无法导出错误报告。 目标路径:{0} 无法导出错误报告,且未能获取目标路径。 @@ -196,6 +202,8 @@ 错误报告-{0}.zip Minecraft 错误报告(*.zip)|*.zip 选择保存位置 + 复制报告 + 错误报告 @@ -2732,6 +2740,8 @@ 禁用自动崩溃分析 + 错误报告可收起并独立查看 + 启用后,游戏崩溃时的错误报告可以收起为右下角入口,并在独立窗口中重新打开,便于一边处理模组冲突一边查看错误原因。 启用后,游戏崩溃时将不再自动弹出崩溃分析页面。 禁用 Java Launch Wrapper 在启动游戏时不使用 Java Wrapper 进行包装。 由于系统为 ARM64 架构,Java Wrapper 已被强制禁用。 diff --git a/PCL.Core/App/Localization/Languages/zh-TW.xaml b/PCL.Core/App/Localization/Languages/zh-TW.xaml index 136e5cbbf..db3bf23d9 100644 --- a/PCL.Core/App/Localization/Languages/zh-TW.xaml +++ b/PCL.Core/App/Localization/Languages/zh-TW.xaml @@ -111,11 +111,17 @@ 崩潰分析失敗。請檢視錯誤詳情。 + 收合 匯出錯誤報告 前往修改 + 在獨立視窗中開啟 檢視日誌 Minecraft 出現錯誤 錯誤報告分析結果 + 丟棄這份錯誤報告 + {0}-{1} + 點擊可在獨立視窗中檢視這次遊戲崩潰的錯誤報告,便於一邊處理問題一邊對照。 + 錯誤報告已收合,可在右下角入口中重新開啟。 複製詳細資訊 無法匯出錯誤報告。 目標路徑:{0} 無法匯出錯誤報告,且未能取得目標路徑。 @@ -196,6 +202,8 @@ 錯誤報告-{0}.zip Minecraft 錯誤報告(*.zip)|*.zip 選擇儲存位置 + 複製報告 + 錯誤報告 @@ -2732,6 +2740,8 @@ 停用自動崩潰分析 + 錯誤報告可收合並獨立檢視 + 啟用後,遊戲崩潰時的錯誤報告可以收合為右下角入口,並在獨立視窗中重新開啟,便於一邊處理模組衝突一邊查看錯誤原因。 啟用後,遊戲崩潰時將不再自動彈出崩潰分析頁面。 停用 Java Launch Wrapper 在啟動遊戲時不使用 Java Wrapper 進行包裝。 由於系統為 ARM64 架構,Java Wrapper 已被強制停用。 diff --git a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml index 94946fc92..b8f48fb1b 100644 --- a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml +++ b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml @@ -46,6 +46,9 @@ + diff --git a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml.cs b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml.cs index dbae31077..d337bfcfb 100644 --- a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml.cs +++ b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgMarkdown.xaml.cs @@ -21,6 +21,7 @@ public MyMsgMarkdown(ModMain.MyMsgBoxConverter converter) AppendUniqueNameSuffix(Btn1); AppendUniqueNameSuffix(Btn2); AppendUniqueNameSuffix(Btn3); + AppendUniqueNameSuffix(Btn4); myConverter = converter; LabTitle.Text = converter.Title; LabCaption.Markdown = converter.Text; @@ -28,6 +29,7 @@ public MyMsgMarkdown(ModMain.MyMsgBoxConverter converter) ConfigurePrimaryButton(converter.Button1, converter.IsWarn); ConfigureSecondaryButton(Btn2, converter.Button2); ConfigureSecondaryButton(Btn3, converter.Button3); + ConfigureSecondaryButton(Btn4, converter.Button4); ShapeLine.StrokeThickness = ModBase.GetWPFSize(1d); } @@ -192,6 +194,22 @@ public void Btn3_Click(object sender, MouseButtonEventArgs e) } } + public void Btn4_Click(object sender, MouseButtonEventArgs e) + { + if (myConverter.IsExited) + return; + if (myConverter.Button4Action is not null) + { + myConverter.Button4Action(); + } + else + { + myConverter.IsExited = true; + myConverter.Result = 4; + Close(); + } + } + private void Drag(object? sender = null, MouseButtonEventArgs? e = null) { try diff --git a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml index 724dfd415..7f26b554d 100644 --- a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml +++ b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml @@ -46,6 +46,9 @@ + diff --git a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml.cs b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml.cs index ec7891685..f73570544 100644 --- a/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml.cs +++ b/Plain Craft Launcher 2/Controls/MyMsg/MyMsgText.xaml.cs @@ -20,12 +20,14 @@ public MyMsgText(ModMain.MyMsgBoxConverter converter) AppendUniqueNameSuffix(Btn1); AppendUniqueNameSuffix(Btn2); AppendUniqueNameSuffix(Btn3); + AppendUniqueNameSuffix(Btn4); myConverter = converter; LabTitle.Text = converter.Title; LabCaption.Text = converter.Text; ConfigurePrimaryButton(converter.Button1, converter.IsWarn); ConfigureSecondaryButton(Btn2, converter.Button2); ConfigureSecondaryButton(Btn3, converter.Button3); + ConfigureSecondaryButton(Btn4, converter.Button4); ShapeLine.StrokeThickness = ModBase.GetWPFSize(1d); } @@ -176,6 +178,22 @@ public void Btn3_Click(object sender, MouseButtonEventArgs e) } } + public void Btn4_Click(object sender, MouseButtonEventArgs e) + { + if (myConverter.IsExited) + return; + if (myConverter.Button4Action is not null) + { + myConverter.Button4Action(); + } + else + { + myConverter.IsExited = true; + myConverter.Result = 4; + Close(); + } + } + private void Drag(object sender, MouseButtonEventArgs e) { try diff --git a/Plain Craft Launcher 2/FormMain.xaml b/Plain Craft Launcher 2/FormMain.xaml index eeef4f432..d4343032e 100644 --- a/Plain Craft Launcher 2/FormMain.xaml +++ b/Plain Craft Launcher 2/FormMain.xaml @@ -224,6 +224,7 @@ Margin="0,0,0,20" Grid.Row="0" Grid.RowSpan="2" Panel.ZIndex="1" /> + ? extraFiles) { - ModMain.frmMain.ShowWindowToTop(); + ModMain.frmMain!.ShowWindowToTop(); + + // 弹窗显示在主界面上,先关掉可能还开着的独立窗口,避免它盖住弹窗 + CrashReportSession.CloseWindow(); var crashContent = _formatter.Format(context, isHandAnalyze); - var resultText = crashContent.Text; - var directFile = context.DirectOpenFile; - var openInstanceSettings = - context.Instance is not null && - crashContent.SuggestedAction == CrashSuggestedAction.OpenInstanceSettings; var title = isHandAnalyze ? Lang.Text("Crash.Dialog.Title.Manual") : Lang.Text("Crash.Dialog.Title.Auto"); - var secondButtonText = _GetSecondButtonText( - isHandAnalyze, - directFile, - openInstanceSettings); - - var thirdButtonText = isHandAnalyze - ? "" - : Lang.Text("Crash.Dialog.Button.ExportReport"); + var report = new CrashReportSnapshot(context, crashContent, title, isHandAnalyze, extraFiles); - var secondButtonAction = _GetSecondButtonAction( - isHandAnalyze, - directFile, - openInstanceSettings); + // 「收起」与「在独立窗口中打开」都需要在弹窗关闭后执行, + // 因此这里只收集按钮,具体动作在弹窗返回后统一分发。 + var canDock = Config.Launch.CrashReportDock; var selectedButton = MsgBoxWrapper.ShowWithCustomButtons( - resultText, + crashContent.Text, title, MsgBoxTheme.Info, true, - new MsgBoxButtonInfo(Lang.Text("Common.Action.Confirm"), 1), - new MsgBoxButtonInfo(secondButtonText, 2, secondButtonAction), - new MsgBoxButtonInfo(thirdButtonText, 3)); + new MsgBoxButtonInfo( + canDock ? Lang.Text("Crash.Dialog.Button.Dock") : Lang.Text("Common.Action.Confirm"), + 1), + // 「打开日志」沿用既有行为:执行动作时不关闭弹窗,便于同时对照日志与报告 + new MsgBoxButtonInfo(_GetSecondButtonText(report), 2, + report.ShowOpenDirectFile ? report.OpenDirectFile : null), + new MsgBoxButtonInfo(_GetThirdButtonText(report), 3), + new MsgBoxButtonInfo(canDock ? Lang.Text("Crash.Dialog.Button.OpenInWindow") : "", 4)); switch (selectedButton) { + case 1: + if (canDock) + Collapse(report); + break; + case 2: - if (openInstanceSettings) - _OpenModLoaderInstallPage(); - else if (directFile is not null) - _OpenDirectFile(directFile); + if (report.ShowOpenInstanceSettings) + report.OpenInstanceSettings(); + else if (report.ShowOpenDirectFile) + report.OpenDirectFile(); break; case 3: - _ExportReport(extraFiles); + if (report.CanExportReport) + report.ExportReport(); break; - } - } - - private static string _GetSecondButtonText( - bool isHandAnalyze, - CrashLogEntry? directFile, - bool openInstanceSettings) - { - if (isHandAnalyze || directFile is null) - return ""; - - return openInstanceSettings - ? Lang.Text("Crash.Dialog.Button.GoToModify") - : Lang.Text("Crash.Dialog.Button.OpenLog"); - } - - private static Action? _GetSecondButtonAction( - bool isHandAnalyze, - CrashLogEntry? directFile, - bool openInstanceSettings) - { - if (isHandAnalyze || - directFile is null || - openInstanceSettings) - return null; - - return () => _OpenDirectFile(directFile); - } - - private void _OpenModLoaderInstallPage() - { - PageInstanceLeft.McInstance = context.Instance; - - ModBase.RunInUi(() => ModMain.frmMain.PageChange( - FormMain.PageType.InstanceSetup, - FormMain.PageSubType.VersionInstall)); - } - private static void _OpenDirectFile(CrashLogEntry directFile) - { - if (File.Exists(directFile.FullPath)) - { - Basics.OpenPath(directFile.FullPath); - return; - } - - var filePath = Path.Combine(Paths.Temp, "Crash.txt"); - - CrashFileIo.WriteText(filePath, string.Join("\r\n", directFile.Lines)); - Basics.OpenPath(filePath); - } - - private void _ExportReport(List? extraFiles) - { - string? fileAddress = null; - - try - { - fileAddress = _SelectReportSavePath(); + case 4: + if (canDock) + { + Collapse(report); + CrashReportSession.OpenWindow(); + } - if (string.IsNullOrEmpty(fileAddress)) - return; - - _exporter.Export(context, fileAddress, extraFiles); - - HintWrapper.Show( - Lang.Text("Crash.Report.Export.Success"), - HintTheme.Success); - - Basics.OpenPath(Path.GetDirectoryName(fileAddress) ?? fileAddress); - } - catch (Exception ex) - { - LogWrapper.Error(ex, "Crash", "导出错误报告失败"); - - var message = _CreateExportFailureMessage(fileAddress, ex); - MsgBoxWrapper.ShowWithCustomButtons( - message, - Lang.Text("Crash.Export.Failed.Title"), - MsgBoxTheme.Error, - false, - new MsgBoxButtonInfo(Lang.Text("Common.Action.Confirm"), 1), - new MsgBoxButtonInfo( - Lang.Text("Crash.Export.Failed.CopyDetails"), - 2, - () => ModBase.ClipboardSet(message, false))); + break; } } - private static string _CreateExportFailureMessage( - string? targetZipPath, - Exception exception) + /// + /// 收起错误报告:弹窗关闭后保留报告,并在右下角显示入口。 + /// + private static void Collapse(CrashReportSnapshot report) { - var summary = string.IsNullOrWhiteSpace(targetZipPath) - ? Lang.Text("Crash.Export.Failed.MessageWithoutPath") - : Lang.Text("Crash.Export.Failed.Message", targetZipPath); - - return ExceptionDetails.Compose(summary, exception); + CrashReportSession.Collapse(report); + HintWrapper.Show(Lang.Text("Crash.Dock.Hint"), HintTheme.Info); } - private static string? _SelectReportSavePath() + private static string _GetSecondButtonText(CrashReportSnapshot report) { - string? fileAddress = null; - - ModBase.RunInUiWait(() => fileAddress = SystemDialogs.SelectSaveFile( - Lang.Text("Crash.Report.SaveDialog.Title"), - _GetDefaultReportFileName(), - Lang.Text("Crash.Report.SaveDialog.Filter"))); - - return fileAddress; + if (report.ShowOpenInstanceSettings) + return Lang.Text("Crash.Dialog.Button.GoToModify"); + return report.ShowOpenDirectFile ? Lang.Text("Crash.Dialog.Button.OpenLog") : ""; } - private static string _GetDefaultReportFileName() + private static string _GetThirdButtonText(CrashReportSnapshot report) { - var time = DateTime.Now - .ToString("G", CultureInfo.InvariantCulture) - .Replace("/", "-") - .Replace(":", ".") - .Replace(" ", "_"); - - return Lang.Text("Crash.Report.SaveDialog.DefaultFileName", time); + return report.CanExportReport ? Lang.Text("Crash.Dialog.Button.ExportReport") : ""; } -} \ No newline at end of file +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml new file mode 100644 index 000000000..3d6eecd68 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml.cs new file mode 100644 index 000000000..a3e9a686c --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportDock.xaml.cs @@ -0,0 +1,56 @@ +using System.Windows; +using System.Windows.Input; + +namespace PCL; + +/// +/// 错误报告收起后在主界面右下角显示的入口。 +/// 只负责展示入口与转发操作,报告数据由 持有。 +/// +public partial class CrashReportDock +{ + public CrashReportDock() + { + InitializeComponent(); + Loaded += CrashReportDock_Loaded; + Unloaded += CrashReportDock_Unloaded; + } + + private void CrashReportDock_Loaded(object sender, RoutedEventArgs e) + { + CrashReportSession.Changed += RefreshState; + RefreshState(); + } + + private void CrashReportDock_Unloaded(object sender, RoutedEventArgs e) + { + CrashReportSession.Changed -= RefreshState; + } + + private void RefreshState() + { + var hasReport = CrashReportSession.HasReport; + Visibility = hasReport ? Visibility.Visible : Visibility.Collapsed; + if (!hasReport) + return; + + // 入口名称形如「1.20.1-Forge-14:32」,便于分辨是哪个实例、哪次崩溃 + LabText.Text = CrashReportSession.EntryLabel; + ToolTip = CrashReportSession.Title; + } + + private void PanBack_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + CrashReportSession.OpenWindow(); + } + + private void BtnOpen_Click(object sender, EventArgs e) + { + CrashReportSession.OpenWindow(); + } + + private void BtnDiscard_Click(object sender, EventArgs e) + { + CrashReportSession.Discard(); + } +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSession.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSession.cs new file mode 100644 index 000000000..e03aabe93 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSession.cs @@ -0,0 +1,143 @@ +using PCL.Core.Logging; + +namespace PCL; + +/// +/// 已生成、但尚未被丢弃的错误报告。 +/// 弹窗收起后由它持有报告,右下角入口与独立窗口都从这里取数据, +/// 因此弹窗、入口与窗口之间不需要互相持有引用。 +/// +internal static class CrashReportSession +{ + private static CrashReportSnapshot? _report; + private static CrashReportWindow? _window; + + /// + /// 当前报告或独立窗口状态发生变化。 + /// 始终在 UI 线程触发。 + /// + public static event Action? Changed; + + /// + /// 是否存在尚未被丢弃的错误报告。 + /// + public static bool HasReport => _report is not null; + + /// + /// 当前错误报告的标题。 + /// + public static string Title => _report?.Title ?? string.Empty; + + /// + /// 当前错误报告在收起入口上显示的名称。 + /// + public static string EntryLabel => _report?.EntryLabel ?? string.Empty; + + /// + /// 收起错误报告:保留报告,并通知界面显示右下角入口。 + /// + public static void Collapse(CrashReportSnapshot report) + { + // 先记录报告本身,再通知界面。这样即使界面尚未就绪也不会丢失数据, + // 入口在加载时会主动读取一次当前状态。 + _report = report; + RunInUi(() => + { + _CloseWindow(); + Changed?.Invoke(); + }); + ModBase.Log("[Crash] 错误报告已收起,可随时在独立窗口中重新打开"); + } + + /// + /// 在独立窗口中打开当前报告。若窗口已打开,则将其激活。 + /// + public static void OpenWindow() + { + var report = _report; + if (report is null) + return; + + RunInUi(() => _OpenWindow(report)); + } + + /// + /// 关闭已经打开的独立窗口,但保留报告本身。 + /// + public static void CloseWindow() + { + RunInUi(_CloseWindow); + } + + /// + /// 丢弃当前报告,并关闭已经打开的独立窗口。 + /// + public static void Discard() + { + _report = null; + RunInUi(() => + { + _CloseWindow(); + Changed?.Invoke(); + }); + } + + private static void _OpenWindow(CrashReportSnapshot report) + { + if (_window is not null) + { + _window.Activate(); + return; + } + + try + { + var window = new CrashReportWindow(report); + window.Closed += (_, _) => + { + if (ReferenceEquals(_window, window)) + _window = null; + }; + _window = window; + window.Show(); + } + catch (Exception ex) + { + _window = null; + LogWrapper.Error(ex, "Crash", "打开错误报告窗口失败"); + } + } + + private static void _CloseWindow() + { + var window = _window; + _window = null; + if (window is null) + return; + + try + { + window.Close(); + } + catch (Exception ex) + { + LogWrapper.Error(ex, "Crash", "关闭错误报告窗口失败"); + } + } + + /// + /// 将界面操作切换到 UI 线程。 + /// 应用尚未启动时不执行任何操作,此时界面就绪后会自行同步状态。 + /// + private static void RunInUi(Action action) + { + try + { + ModBase.RunInUi(action); + } + catch (Exception ex) + { + LogWrapper.Error(ex, "Crash", "更新错误报告状态失败"); + } + } +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSnapshot.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSnapshot.cs new file mode 100644 index 000000000..8926a9ab8 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportSnapshot.cs @@ -0,0 +1,213 @@ +using PCL.Core.App; +using PCL.Core.App.Localization; +using PCL.Core.Logging; +using PCL.Core.UI; +using System.Globalization; +using System.IO; + +namespace PCL; + +/// +/// 一次崩溃分析的展示与操作快照。 +/// 它只描述「要展示什么」和「能做什么」,不依赖弹窗与窗体, +/// 因此错误报告弹窗、右下角入口与独立窗口可以共用同一份实现。 +/// +internal sealed class CrashReportSnapshot +{ + private readonly CrashAnalysisContext _context; + private readonly CrashReportExporter _exporter = new(); + private readonly IReadOnlyList _extraFiles; + + public CrashReportSnapshot( + CrashAnalysisContext context, + CrashDialogContent content, + string title, + bool isHandAnalyze, + IReadOnlyList? extraFiles) + { + _context = context; + _extraFiles = extraFiles ?? []; + Title = title; + Text = content.Text; + EntryLabel = _CreateEntryLabel(context); + DirectFile = isHandAnalyze ? null : context.DirectOpenFile; + CanExportReport = !isHandAnalyze; + ShowOpenInstanceSettings = DirectFile is not null && + context.Instance is not null && + content.SuggestedAction == CrashSuggestedAction.OpenInstanceSettings; + ShowOpenDirectFile = DirectFile is not null && !ShowOpenInstanceSettings; + } + + /// + /// 报告标题,与弹窗标题一致。 + /// + public string Title { get; } + + /// + /// 报告正文,与弹窗内容一致。 + /// + public string Text { get; } + + /// + /// 收起入口上显示的名称,格式为「游戏版本名-时间」。 + /// + public string EntryLabel { get; } + + /// + /// 是否可以在弹窗上提供「导出错误报告」。手动分析不提供该操作。 + /// + public bool CanExportReport { get; } + + /// + /// 是否可以在弹窗上提供「打开日志」。 + /// + public bool ShowOpenDirectFile { get; } + + /// + /// 是否可以在弹窗上提供「前往修改」。 + /// + public bool ShowOpenInstanceSettings { get; } + + private CrashLogEntry? DirectFile { get; } + + /// + /// 生成收起入口上的名称。 + /// 自动分析时使用游戏版本名,手动分析时没有实例信息,退回报告标题。 + /// + private static string _CreateEntryLabel(CrashAnalysisContext context) + { + var instanceName = context.Instance?.Name; + if (string.IsNullOrWhiteSpace(instanceName)) + instanceName = Lang.Text("Crash.Report.Window.Title"); + + var time = DateTime.Now.ToString("HH:mm", CultureInfo.InvariantCulture); + + try + { + return Lang.Text("Crash.Dock.Entry", instanceName, time); + } + catch (FormatException ex) + { + // 语言文件中的格式字符串有误时退回简单拼接,不影响报告本身 + LogWrapper.Error(ex, "Crash", "格式化错误报告入口名称失败"); + return $"{instanceName}-{time}"; + } + } + + /// + /// 把报告正文复制到剪贴板。 + /// + public void CopyReport() + { + ModBase.ClipboardSet(Text); + } + + /// + /// 打开与崩溃直接相关的日志文件。 + /// + public void OpenDirectFile() + { + if (DirectFile is null) + return; + + if (File.Exists(DirectFile.FullPath)) + { + Basics.OpenPath(DirectFile.FullPath); + return; + } + + var filePath = Path.Combine(Paths.Temp, "Crash.txt"); + + CrashFileIo.WriteText(filePath, string.Join("\r\n", DirectFile.Lines)); + Basics.OpenPath(filePath); + } + + /// + /// 跳转到实例的模组管理页面。 + /// + public void OpenInstanceSettings() + { + if (_context.Instance is null) + return; + + PageInstanceLeft.McInstance = _context.Instance; + + ModBase.RunInUi(() => ModMain.frmMain!.PageChange( + FormMain.PageType.InstanceSetup, + FormMain.PageSubType.VersionInstall)); + } + + /// + /// 导出错误报告压缩包。 + /// + public void ExportReport() + { + string? fileAddress = null; + + try + { + fileAddress = _SelectReportSavePath(); + + if (string.IsNullOrEmpty(fileAddress)) + return; + + _exporter.Export(_context, fileAddress, _extraFiles); + + HintWrapper.Show( + Lang.Text("Crash.Report.Export.Success"), + HintTheme.Success); + + Basics.OpenPath(Path.GetDirectoryName(fileAddress) ?? fileAddress); + } + catch (Exception ex) + { + LogWrapper.Error(ex, "Crash", "导出错误报告失败"); + + var message = _CreateExportFailureMessage(fileAddress, ex); + MsgBoxWrapper.ShowWithCustomButtons( + message, + Lang.Text("Crash.Export.Failed.Title"), + MsgBoxTheme.Error, + false, + new MsgBoxButtonInfo(Lang.Text("Common.Action.Confirm"), 1), + new MsgBoxButtonInfo( + Lang.Text("Crash.Export.Failed.CopyDetails"), + 2, + () => ModBase.ClipboardSet(message, false))); + } + } + + private static string _CreateExportFailureMessage( + string? targetZipPath, + Exception exception) + { + var summary = string.IsNullOrWhiteSpace(targetZipPath) + ? Lang.Text("Crash.Export.Failed.MessageWithoutPath") + : Lang.Text("Crash.Export.Failed.Message", targetZipPath); + + return ExceptionDetails.Compose(summary, exception); + } + + private static string? _SelectReportSavePath() + { + string? fileAddress = null; + + ModBase.RunInUiWait(() => fileAddress = SystemDialogs.SelectSaveFile( + Lang.Text("Crash.Report.SaveDialog.Title"), + _GetDefaultReportFileName(), + Lang.Text("Crash.Report.SaveDialog.Filter"))); + + return fileAddress; + } + + private static string _GetDefaultReportFileName() + { + var time = DateTime.Now + .ToString("G", CultureInfo.InvariantCulture) + .Replace("/", "-") + .Replace(":", ".") + .Replace(" ", "_"); + + return Lang.Text("Crash.Report.SaveDialog.DefaultFileName", time); + } +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml new file mode 100644 index 000000000..1e138f808 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml.cs new file mode 100644 index 000000000..9f38b8e2d --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashReportWindow.xaml.cs @@ -0,0 +1,105 @@ +using System.Windows; +using System.Windows.Input; +using PCL.Core.Logging; + +namespace PCL; + +/// +/// 以独立窗口展示一次崩溃分析的错误报告。 +/// 窗口只消费 ,不参与崩溃分析流程。 +/// +public partial class CrashReportWindow +{ + private readonly CrashReportSnapshot _report; + + internal CrashReportWindow(CrashReportSnapshot report) + { + _report = report; + InitializeComponent(); + + Title = report.Title; + LabTitle.Text = report.Title; + TextReport.Text = report.Text; + + BtnOpenLog.Visibility = report.ShowOpenDirectFile ? Visibility.Visible : Visibility.Collapsed; + BtnModify.Visibility = report.ShowOpenInstanceSettings ? Visibility.Visible : Visibility.Collapsed; + BtnExport.Visibility = report.CanExportReport ? Visibility.Visible : Visibility.Collapsed; + + // 关闭是本窗口的主操作,使用主题色高亮 + BtnClose.ColorType = MyIconTextButton.ColorState.Highlight; + + TryAttachToMainWindow(); + } + + /// + /// 将窗口挂到主窗口上,使其跟随主窗口显示、隐藏,且不影响任务栏。 + /// + private void TryAttachToMainWindow() + { + try + { + if (ModMain.frmMain is { IsLoaded: true, IsVisible: true }) + Owner = ModMain.frmMain; + } + catch (Exception ex) + { + LogWrapper.Error(ex, "Crash", "设置错误报告窗口的父窗口失败"); + } + } + + private void PanTitle_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton != MouseButtonState.Pressed) + return; + + // 与主窗体一致:仅当鼠标直接位于标题栏空白处时才拖动,避免抢占标题栏按钮的点击 + if (sender is not FrameworkElement { IsMouseDirectlyOver: true }) + return; + + try + { + DragMove(); + } + catch (Exception ex) + { + LogWrapper.Error(ex, "Crash", "拖动错误报告窗口失败"); + } + } + + private void BtnCopy_Click(object sender, ModBase.RouteEventArgs e) + { + _report.CopyReport(); + } + + private void BtnOpenLog_Click(object sender, ModBase.RouteEventArgs e) + { + _report.OpenDirectFile(); + } + + private void BtnModify_Click(object sender, ModBase.RouteEventArgs e) + { + _report.OpenInstanceSettings(); + } + + private void BtnExport_Click(object sender, ModBase.RouteEventArgs e) + { + // 打包压缩包可能耗时较久,放到后台线程,避免卡住界面 + ModBase.RunInThread(() => _report.ExportReport()); + } + + private void BtnClose_Click(object sender, ModBase.RouteEventArgs e) + { + Close(); + } + + private void BtnTitleClose_Click(object sender, EventArgs e) + { + Close(); + } + + private void Window_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + Close(); + } +} diff --git a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashResultFormatter.cs b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashResultFormatter.cs index 5ce420704..db469cc94 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashResultFormatter.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/CrashAnalysis/Presentation/CrashResultFormatter.cs @@ -9,7 +9,8 @@ internal sealed class CrashResultFormatter { public CrashDialogContent Format( CrashAnalysisContext context, - bool isHandAnalyze) + bool isHandAnalyze + ) { var result = context.Result ?? new CrashAnalysisResult(); @@ -40,9 +41,7 @@ private static CrashDialogContent _CreateUnknownContent(bool isHandAnalyze) CrashSuggestedAction.None); } - private static string _AppendFollowUps( - string text, - IReadOnlyCollection specs) + private static string _AppendFollowUps(string text, IReadOnlyCollection specs) { var followUps = new List(); @@ -76,9 +75,7 @@ private static string _Render(CrashMessageSpec spec) Lang.Text(spec.SuggestionKey, spec.SuggestionArgs)); } - private static CrashMessageSpec _CreateMessageSpec( - CrashFinding finding, - string combinedLogText) + private static CrashMessageSpec _CreateMessageSpec(CrashFinding finding, string combinedLogText) { var additional = finding.Details.ToList(); diff --git a/Plain Craft Launcher 2/Modules/ModMain.cs b/Plain Craft Launcher 2/Modules/ModMain.cs index 8720a0842..e28982a96 100644 --- a/Plain Craft Launcher 2/Modules/ModMain.cs +++ b/Plain Craft Launcher 2/Modules/ModMain.cs @@ -253,6 +253,13 @@ public class MyMsgBoxConverter /// public Action Button3Action; + public string Button4 = ""; + + /// + /// 点击第四个按钮将执行该方法,不关闭弹窗。 + /// + public Action Button4Action; + /// /// 输入模式:文本框的文本。 /// 选择模式:需要放进去的 List(Of MyListItem)。 @@ -340,18 +347,21 @@ public enum MyMsgBoxType /// 是否为警告弹窗,若为 True,弹窗配色和背景会变为红色。 public static int MyMsgBox(string caption, string? title = null, string? button1 = null, string? button2 = "", string? button3 = "", bool isWarn = false, bool highLight = true, bool forceWait = false, - Action button1Action = null, Action button2Action = null, Action button3Action = null) + Action button1Action = null, Action button2Action = null, Action button3Action = null, + string? button4 = "", Action button4Action = null) { title ??= GetDefaultDialogTitle(); button1 ??= GetDefaultConfirmText(); button2 ??= ""; button3 ??= ""; + button4 ??= ""; // 将弹窗列入队列 var converter = new MyMsgBoxConverter { - Type = MyMsgBoxType.Text, Button1 = button1, Button2 = button2, Button3 = button3, Text = caption, + Type = MyMsgBoxType.Text, Button1 = button1, Button2 = button2, Button3 = button3, Button4 = button4, + Text = caption, IsWarn = isWarn, Title = title, HighLight = highLight, ForceWait = true, Button1Action = button1Action, - Button2Action = button2Action, Button3Action = button3Action + Button2Action = button2Action, Button3Action = button3Action, Button4Action = button4Action }; WaitingMyMsgBox.Add(converter); if (ModBase.RunInUi()) @@ -435,18 +445,21 @@ public static int MyMsgBox(string caption, string? title = null, string? button1 /// 是否为警告弹窗,若为 True,弹窗配色和背景会变为红色。 public static int MyMsgBoxMarkdown(string caption, string? title = null, string? button1 = null, string? button2 = "", string? button3 = "", bool isWarn = false, bool highLight = true, bool forceWait = false, - Action button1Action = null, Action button2Action = null, Action button3Action = null) + Action button1Action = null, Action button2Action = null, Action button3Action = null, + string? button4 = "", Action button4Action = null) { title ??= GetDefaultDialogTitle(); button1 ??= GetDefaultConfirmText(); button2 ??= ""; button3 ??= ""; + button4 ??= ""; // 将弹窗列入队列 var converter = new MyMsgBoxConverter { - Type = MyMsgBoxType.Markdown, Button1 = button1, Button2 = button2, Button3 = button3, Text = caption, - IsWarn = isWarn, Title = title, HighLight = highLight, ForceWait = true, Button1Action = button1Action, - Button2Action = button2Action, Button3Action = button3Action + Type = MyMsgBoxType.Markdown, Button1 = button1, Button2 = button2, Button3 = button3, Button4 = button4, + Text = caption, IsWarn = isWarn, Title = title, HighLight = highLight, ForceWait = true, + Button1Action = button1Action, Button2Action = button2Action, Button3Action = button3Action, + Button4Action = button4Action }; WaitingMyMsgBox.Add(converter); if (ModBase.RunInUi()) @@ -698,11 +711,14 @@ public static void MsgBoxWrapper_OnShow(string message, string caption, ICollect var btnAct2 = (Action)(buttons.Count < 2 ? (object)null : buttons.ElementAt(1).OnClick); var btnText3 = buttons.Count < 3 ? "" : buttons.ElementAt(2).Context; var btnAct3 = (Action)(buttons.Count < 3 ? (object)null : buttons.ElementAt(2).OnClick); + var btnText4 = buttons.Count < 4 ? "" : buttons.ElementAt(3).Context; + var btnAct4 = (Action)(buttons.Count < 4 ? (object)null : buttons.ElementAt(3).OnClick); var isWarn = theme == MsgBoxTheme.Warning || theme == MsgBoxTheme.Error; result = MyMsgBox(message, caption, btnText1, btnText2, btnText3, isWarn, forceWait: block, - button1Action: btnAct1, button2Action: btnAct2, button3Action: btnAct3); + button1Action: btnAct1, button2Action: btnAct2, button3Action: btnAct3, + button4: btnText4, button4Action: btnAct4); } #endregion diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml index 12ad78264..fd772f63a 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml @@ -302,6 +302,8 @@ ToolTipService.Placement="Right" ToolTip="{DynamicResource Setup.Launch.Advanced.DisableLwjglUnsafeAgent.ToolTip}" /> + diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml.cs index 56f4c7b06..ed512e4dd 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLaunch.xaml.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -79,6 +79,8 @@ public void Reload() CheckAdvanceNoJavaw.Checked = Config.Launch.NoJavaw; CheckAdvanceDisableLwjglUnsafeAgent.Checked = Config.Launch.DisableLwjglUnsafeAgent; CheckAdvanceDisableCrashAnalysis.Checked = Config.Launch.DisableCrashAnalysis; + CheckAdvanceCrashReportDock.Checked = Config.Launch.CrashReportDock; + CheckAdvanceCrashReportDock.IsEnabled = !Config.Launch.DisableCrashAnalysis; CheckAdvanceLockMemory.Checked = Config.Launch.LockMemory; if (SystemInfo.IsArm64System) { @@ -179,6 +181,8 @@ private void CheckBoxChange(object senderRaw, bool user) var sender = (MyCheckBox)senderRaw; if (ModAnimation.AniControlEnabled == 0) SetByTag(sender.Tag?.ToString(), sender.Checked); + if (sender.Tag?.ToString() == "LaunchAdvanceDisableCrashAnalysis") + CheckAdvanceCrashReportDock.IsEnabled = sender.Checked != true; } private static void SetByTag(string tag, object value)