diff --git a/PyReconstruct/modules/gui/utils/errors.py b/PyReconstruct/modules/gui/utils/errors.py
index ae9c558f..694edf0d 100644
--- a/PyReconstruct/modules/gui/utils/errors.py
+++ b/PyReconstruct/modules/gui/utils/errors.py
@@ -1,27 +1,120 @@
import sys
+import html
+import platform
+import traceback as _traceback
-from PySide6.QtWidgets import QApplication, QMessageBox
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QFontDatabase
+from PySide6.QtWidgets import (
+ QApplication,
+ QMessageBox,
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLabel,
+ QPlainTextEdit,
+ QPushButton,
+)
from PyReconstruct.modules.constants import gh_issues
-def customExcepthook(exctype, value, traceback):
- """Global exception hook: Show notification."""
- sys.__excepthook__(exctype, value, traceback) # call default exception hook
-
- message = (
- f"An error occurred:\n\n{str(value)}\n\n"
- "(See console for more info.)\n\n"
- "If you think this is a bug or need help, "
- "please issue a bug report at:\n\n"
- f"{gh_issues}"
+def build_error_report(exctype, value, tb) -> str:
+ """Assemble a paste-ready crash report: version / OS / Python context plus the
+ full traceback. Never raises -- the global exception hook must not itself fail.
+ """
+ lines = ["PyReconstruct error report"]
+ try:
+ from PyReconstruct.modules.backend.updater.install_info import current_version_str
+ lines.append(f"Version: {current_version_str()}")
+ except Exception:
+ pass
+ try:
+ lines.append(f"Platform: {platform.platform()}")
+ lines.append(f"Python: {platform.python_version()}")
+ except Exception:
+ pass
+ lines.append("")
+ try:
+ tb_text = "".join(_traceback.format_exception(exctype, value, tb)).rstrip()
+ except Exception:
+ tb_text = f"{getattr(exctype, '__name__', exctype)}: {value}"
+ lines.append(tb_text or "(no traceback available)")
+ return "\n".join(lines)
+
+
+class ErrorReportDialog(QDialog):
+ """Modal error window that shows a copyable report.
+
+ The frozen app has no console, so lay users cannot read the traceback that
+ ``sys.__excepthook__`` prints to stderr. This shows the full report inline and
+ a one-click "Copy report to clipboard" button so it can be pasted into a bug
+ report or email.
+ """
+
+ def __init__(self, summary_html: str, report: str, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Error")
+ self._report = report
+
+ layout = QVBoxLayout(self)
+
+ heading = QLabel(summary_html)
+ heading.setWordWrap(True)
+ heading.setTextFormat(Qt.RichText)
+ heading.setTextInteractionFlags(Qt.TextBrowserInteraction)
+ heading.setOpenExternalLinks(True)
+ layout.addWidget(heading)
+
+ view = QPlainTextEdit(report)
+ view.setReadOnly(True)
+ view.setLineWrapMode(QPlainTextEdit.NoWrap)
+ view.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
+ layout.addWidget(view)
+
+ buttons = QHBoxLayout()
+ self._copy_btn = QPushButton("Copy report to clipboard")
+ self._copy_btn.clicked.connect(self._copyReport)
+ close_btn = QPushButton("Close")
+ close_btn.clicked.connect(self.accept)
+ close_btn.setDefault(True)
+ buttons.addWidget(self._copy_btn)
+ buttons.addStretch()
+ buttons.addWidget(close_btn)
+ layout.addLayout(buttons)
+
+ self.resize(720, 480)
+
+ def _copyReport(self):
+ clipboard = QApplication.clipboard()
+ if clipboard is not None:
+ clipboard.setText(self._report)
+ self._copy_btn.setText("Copied ✓")
+
+
+def customExcepthook(exctype, value, tb):
+ """Global exception hook: show an error window with a copyable report."""
+ sys.__excepthook__(exctype, value, tb) # keep console output for terminal users
+
+ report = build_error_report(exctype, value, tb)
+
+ summary = (
+ f"An error occurred:
{html.escape(str(value))}
"
+ "Click Copy report to clipboard below, then paste it into a bug "
+ "report or email so we can help.
"
+ f'Report bugs at {gh_issues}'
)
active_window = QApplication.activeWindow()
parent = active_window if active_window else None
-
- QMessageBox.critical(parent, "Error", message, QMessageBox.Ok)
-
+
+ try:
+ ErrorReportDialog(summary, report, parent).exec()
+ except Exception:
+ # the error handler itself must never fail -- fall back to a plain box
+ QMessageBox.critical(
+ parent, "Error", f"{str(value)}\n\n{gh_issues}", QMessageBox.Ok
+ )
+
if active_window:
active_window.activateWindow()
-
diff --git a/tests/test_error_report.py b/tests/test_error_report.py
new file mode 100644
index 00000000..72fd0852
--- /dev/null
+++ b/tests/test_error_report.py
@@ -0,0 +1,27 @@
+"""The crash-report builder behind the copyable error dialog.
+
+The frozen app has no console, so the report (shown + copied to clipboard) must
+carry the full traceback + context, and the builder must never itself raise
+(it runs inside the global exception hook).
+"""
+import sys
+
+from PyReconstruct.modules.gui.utils.errors import build_error_report
+
+
+def test_report_has_traceback_and_context():
+ try:
+ raise ValueError("boom-marker-123")
+ except ValueError:
+ exctype, value, tb = sys.exc_info()
+ report = build_error_report(exctype, value, tb)
+ assert "boom-marker-123" in report # the exception message
+ assert "ValueError" in report # the exception type
+ assert "Traceback" in report # the stack
+ assert "Platform:" in report and "Python:" in report # context
+
+
+def test_report_never_raises_on_bad_input():
+ # the exception hook must not fail even on degenerate inputs
+ assert isinstance(build_error_report(None, None, None), str)
+ assert isinstance(build_error_report(ValueError, ValueError("x"), None), str)