diff --git a/Furious/Actions/AGENTS.md b/Furious/Actions/AGENTS.md index 518f6cf..6ca19c6 100644 --- a/Furious/Actions/AGENTS.md +++ b/Furious/Actions/AGENTS.md @@ -25,8 +25,13 @@ owned commands and presentation without becoming a workflow authority. - `AppQAction.callback` is a deliberate strong reference. The action owner must not outlive a captured receiver, and a transient/repeated receiver uses the weak named-method facilities required by `Furious/Qt/AGENTS.md`. - Clipboard text, files, QR images, share links, and plugin results may contain credentials. A character limit is - not redaction: avoid echoing secret-bearing input in failure dialogs as well as logs. Inspect error presentation - independently from parser diagnostics; controlled validation failure must not disclose the rejected payload. + not redaction: never log rejected payloads. Clipboard import-error dialogs retain their existing length-limited + preview by explicit product choice; do not change that preview policy without a request. Inspect other error + presentation independently from parser diagnostics. + Connection-error diagnostics retain only the displayed failure text for explicit copying; copying leaves the dialog + open. Open Logs resolves the current application window and rechecks native validity after dialog completion and + navigation. It opens the existing log page without retrying a stale connection or acquiring another workflow owner. + `DialogBehaviorTest` in `tests/test_ui_behavior.py` and the compiled lifetime fixture verify these actions. - Screen capture and QR decoding currently run synchronously; batching the resulting imports does not make capture interruptible. If moved to workers, transfer data through an owned GUI-thread continuation without retaining transient windows. Each screen-capture action owns a separate native capture handle, including separate tray/page diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py index e9c9beb..d729735 100644 --- a/Furious/Actions/Connection.py +++ b/Furious/Actions/Connection.py @@ -24,17 +24,21 @@ from Furious.Controllers.ConnectionController import ( ConnectionState, ) from Furious.Frozenlib import ( + APP, AppConnectionController, AppSettings, AppSystemTray, ) -from Furious.Qt import AppQAction, AppQMessageBox, bootstrapIcon +from Furious.Qt import AppQAction, AppQMessageBox, bootstrapIcon, connectWeakly from Furious.Qt import gettext as _ from Furious.Widget.ConnectionProgressWidget import ConnectionProgressWidget from PySide6 import QtCore +from PySide6.QtWidgets import QApplication -__all__ = ['ConnectAction'] +from shiboken6 import isValid + +__all__ = ['ConnectAction', 'ConnectionErrorMessageBox'] _TRANSLATABLE_CONNECTION_STATES = ( _('Connect'), @@ -44,6 +48,73 @@ _TRANSLATABLE_CONNECTION_STATES = ( ) +class ConnectionErrorMessageBox(AppQMessageBox): + """Present one captured connection failure with copy and log-navigation actions.""" + + def __init__(self, error: ConnectionError): + super().__init__( + icon=self.Icon.Critical, + heading=error.title, + text=error.message, + buttons=self.StandardButton.Close, + ) + + self.setInformativeText(error.details) + + self.textLabel.setTextFormat(QtCore.Qt.TextFormat.PlainText) + self.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.PlainText) + + self._errorText = '\n\n'.join( + text for text in (error.title, error.message, error.details) if text + ) + + self.copyErrorButton = self.addButton( + _('Copy Error'), self.ButtonRole.ActionRole, closeOnClick=False + ) + self.openLogsButton = self.addButton(_('Open Logs'), self.ButtonRole.ActionRole) + + connectWeakly(self.copyErrorButton.clicked, self, '_copyError') + connectWeakly(self.openLogsButton.clicked, self, '_openLogs') + + window = getattr(APP(), 'mainWindow', None) + + self.openLogsButton.setEnabled(window is not None and isValid(window)) + + self.setDefaultButton(self.StandardButton.Close) + self.setEscapeButton(self.StandardButton.Close) + + @QtCore.Slot(bool) + def _copyError(self, _checked=False): + """Copy the original failure while keeping its dialog open.""" + QApplication.clipboard().setText(self._errorText) + + @QtCore.Slot(bool) + def _openLogs(self, _checked=False): + """Resolve the application window after the error dialog has completed.""" + window = getattr(APP(), 'mainWindow', None) + + if window is None or not isValid(window): + return + + if window.isMinimized(): + window.showNormal() + else: + window.show() + + if not isValid(window): + return + + window.showLogPage() + + if not isValid(window): + return + + window.raise_() + + if isValid(window): + window.activateWindow() + + class ConnectAction(AppQAction): """Adapt connection state and operations to a tray QAction.""" @@ -130,14 +201,7 @@ class ConnectAction(AppQAction): if not isinstance(error, ConnectionError): return - mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) - mbox.setHeading(error.title) - mbox.setText(error.message) - - if error.details: - mbox.setInformativeText(error.details) - - mbox.open() + ConnectionErrorMessageBox(error).open() def triggeredCallback(self, checked): """Delegate the requested operation to the shared controller.""" diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 67339af..fc77195 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -3628,5 +3628,21 @@ TRANSLATION = { "RU": "IP-адреса в обход интерфейса TUN (через запятую)", "ZH": "绕过TUN适配器接口的IP地址(以逗号分隔)", "isReviewed": "True" + }, + "Copy Error": { + "source": [ + "Furious.Actions.Connection" + ], + "RU": "Скопировать ошибку", + "ZH": "复制错误信息", + "isReviewed": "True" + }, + "Open Logs": { + "source": [ + "Furious.Actions.Connection" + ], + "RU": "Открыть журнал", + "ZH": "打开日志", + "isReviewed": "True" } } diff --git a/Furious/Qt/AGENTS.md b/Furious/Qt/AGENTS.md index 82347f1..5e92b53 100644 --- a/Furious/Qt/AGENTS.md +++ b/Furious/Qt/AGENTS.md @@ -36,6 +36,10 @@ behavior, and lifetime primitives; pages and services consume them without creat an unparented wrapper; `AppQMainWindow.show()` adds its own visible-window retention until accepted close. Do not solve ambiguity by global retention, indiscriminate delete-on-close, routine `gc.collect()`, or broad deleted-wrapper suppression. +- Message-box inline actions may use `addButton(..., closeOnClick=False)` to publish the existing click signal while + leaving the dialog open. Ordinary buttons retain their existing result/dismissal behavior. Keep callbacks owned by + the button's native lifetime and capture only required plain context, not the transient box; connection-error copy + and normal close/navigation cases in `tests/test_ui_behavior.py` exercise this contract. ## Signals, threads, and async Qt work diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py index 3dce636..f9009f6 100644 --- a/Furious/Qt/QtWidgets.py +++ b/Furious/Qt/QtWidgets.py @@ -1514,14 +1514,16 @@ class AppQMessageBox(AppQTransientDialog): """Return the currently displayed icon pixmap.""" return self.iconLabel.pixmap() - def addButton(self, button, role=None): - """Add a standard button or a custom text/role button.""" + def addButton(self, button, role=None, *, closeOnClick=True): + """Add a button, optionally keeping the dialog open for inline actions.""" if isinstance(button, self.StandardButton): standardButton = button existing = self._standardButtonMap.get(standardButton) if existing is not None: + existing.setProperty('messageBoxCloseOnClick', bool(closeOnClick)) + return existing standardRole = { @@ -1547,11 +1549,14 @@ class AppQMessageBox(AppQTransientDialog): self._standardButtons |= standardButton - return self._createButton( + createdButton = self._createButton( self._standardButtonText(standardButton), standardRole, standardButton, ) + createdButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick)) + + return createdButton if isinstance(button, QAbstractButton): customButton = button @@ -1561,12 +1566,17 @@ class AppQMessageBox(AppQTransientDialog): self._registerButton(customButton, role) self._rebuildButtonLayout() + customButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick)) + return customButton if role is None: raise TypeError('a custom button requires a QMessageBox.ButtonRole') - return self._createButton(button, role) + createdButton = self._createButton(button, role) + createdButton.setProperty('messageBoxCloseOnClick', bool(closeOnClick)) + + return createdButton @QtCore.Slot(object, bool) def _handleButtonClicked(self, button, _checked=False): @@ -1688,6 +1698,9 @@ class AppQMessageBox(AppQTransientDialog): if not isValid(self): return + if isValid(button) and button.property('messageBoxCloseOnClick') is False: + return + standardButton = self.standardButton(button) if standardButton != self.StandardButton.NoButton: diff --git a/tests/README.md b/tests/README.md index aa2390b..25d5fea 100644 --- a/tests/README.md +++ b/tests/README.md @@ -114,7 +114,7 @@ worker. Choose tests by the changed contract rather than by filename alone. | [test_main_window_geometry.py](test_main_window_geometry.py) | First-show lifecycle, restored/default/legacy geometry, main/routing window reuse, session-only navigation state, protection of never-shown windows' saved geometry. | | [test_dialog_geometry.py](test_dialog_geometry.py) | Dialog show/open/exec preparation, sizing/centering, failure cleanup, specialized message-box geometry and transient destruction. | | [test_layout_matrix.py](test_layout_matrix.py) | Navigation and message-box layouts in fresh processes at scale factors 1, 1.25, 1.5, and 2, under both themes. | -| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings organization, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes, shared Home/tray state. | +| [test_ui_behavior.py](test_ui_behavior.py) | Translation and editor mappings, settings organization, stable server/routing moves, real QR rendering/decoding, incremental log filtering/tail behavior, connected-routing change notices, message boxes and connection-error copy/log navigation, shared Home/tray state. | | [test_qt_interactions.py](test_qt_interactions.py) | Real keyboard/mouse/focus and proxy mapping, scoped shortcuts, sorting/selection, stable subscription deletion confirmations, debounced Home search, Tests-menu selection color, shared settings, batched profile mutation and cancellation. | | [test_qr_export_scalability.py](test_qr_export_scalability.py) | Production capture cap, immediate single export, incremental yielding, failure/cancel/close paths, immutable snapshots, window-owned state destruction. | | [test_stylesheet_states.py](test_stylesheet_states.py) | Targeted rendering/alpha/geometry assertions for table/list insets, popup corners, clear buttons, focus/disabled states, and stylesheet composition. | @@ -333,6 +333,8 @@ masks, reopen generations, animations, menus, and view-owned confirmations. It r JSON diagnostics and asserts captured Qt callback exceptions are absent. It also checks destruction during action/theme/startup callbacks, clearing borrowed menus in either destruction order, and bounded sender replacement registrations. +Connection recovery dialogs exercise Copy Error without dismissal and Open Logs +through native destruction, with weak-wrapper and compiled callback-retention checks. For compiler-sensitive work, compile this fixture separately with Nuitka's PySide6 plugin, its imported support code, and required data, then repeat the diff --git a/tests/fixtures/editor_lifetime_probe.py b/tests/fixtures/editor_lifetime_probe.py index 4bf7b15..806cfea 100644 --- a/tests/fixtures/editor_lifetime_probe.py +++ b/tests/fixtures/editor_lifetime_probe.py @@ -25,9 +25,11 @@ from Furious.Backends.Xray.AssetListView import XrayAssetListView import Furious.Backends.Xray.AssetListView as assetModule import Furious.Actions.Import as importModule from Furious.Actions.Routing import RoutingAction +from Furious.Actions.Connection import ConnectionErrorMessageBox from Furious.Application.TrayIcon import TrayIcon from Furious.Application.DesktopApplication import DesktopApplication from Furious.Controllers import ConnectionController, RoutingController +from Furious.Controllers.ConnectionController import ConnectionError from Furious.Plugins import RoutingOption from Furious.Plugins import blankProfile, initializePluginRegistry from Furious.Qt import ( @@ -36,6 +38,7 @@ from Furious.Qt import ( AppQMenuPushButton, AppQDialog, AppQMessageBox, + AppQMainWindow, ThemeTransition, connectWeakly, ) @@ -97,6 +100,73 @@ PROTOCOL_PATTERNS = { CLOSE_METHODS = ('accept', 'close', 'reject') +def runConnectionRecoveryProbe(iterations=100): + """Check compiled diagnostic-button dispatch and transient destruction.""" + app = application() + hadWindow = hasattr(app, 'mainWindow') + previousWindow = getattr(app, 'mainWindow', None) + previousClipboard = app.clipboard().text() + window = AppQMainWindow() + logPageOpens = [] + window.showLogPage = lambda: logPageOpens.append(True) + app.mainWindow = window + references = [] + destroyed = [] + protectedMethods = getattr( + sys.modules.get('PySide6-postLoad', PySide6), '_protected', None + ) + protectedBefore = len(protectedMethods) if protectedMethods is not None else None + + try: + for _ in range(iterations): + box = ConnectionErrorMessageBox( + ConnectionError('Unable to connect', 'Fixture failure', 'Native detail') + ) + references.append(weakref.ref(box)) + box.destroyed.connect(lambda *_args: destroyed.append(True)) + box.open() + processQtEvents() + + box.copyErrorButton.click() + assert box.isVisible() + assert ( + app.clipboard().text() + == 'Unable to connect\n\nFixture failure\n\nNative detail' + ) + + box.openLogsButton.click() + processQtEvents() + + assert not isValid(box) + del box + + assert len(logPageOpens) == iterations + assert len(destroyed) == iterations + assert all(reference() is None for reference in references) + assert not AppQDialog._openDialogs + growth = ( + len(protectedMethods) - protectedBefore + if protectedBefore is not None + else None + ) + assert growth in (None, 0), growth + + return { + 'connectionRecoveryDialogs': iterations, + 'protectedMethodGrowth': growth, + } + finally: + app.clipboard().setText(previousClipboard) + + if hadWindow: + app.mainWindow = previousWindow + else: + del app.mainWindow + + deleteQObject(window) + processQtEvents() + + class _ReentrantAction(AppQAction): """Make stale activation visible as a forbidden native operation.""" @@ -1191,6 +1261,7 @@ def main(): ) try: + print(json.dumps(runConnectionRecoveryProbe(arguments.iterations))) print(json.dumps(runReentrantLifetimeProbe(arguments.iterations))) print(json.dumps(runSingletonIPCProbe(arguments.iterations))) print(json.dumps(runThreadOwnershipProbe(arguments.iterations))) diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index c45f4b1..5c7aa3e 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -65,7 +65,7 @@ from Furious.Backends.Xray.VmessEditor import ( GuiVMessGroupBoxBasic, VmessEditor, ) -from Furious.Actions.Connection import ConnectAction +from Furious.Actions.Connection import ConnectAction, ConnectionErrorMessageBox from Furious.Controllers.ConnectionController import ( ConnectionError, ConnectionController, @@ -140,6 +140,8 @@ from PySide6.QtWidgets import ( QWidget, ) +from shiboken6 import isValid, delete as deleteQObject + from tests.support import ( application, collectAtBoundary, @@ -3564,6 +3566,128 @@ class DialogBehaviorTest(unittest.TestCase): messageBox.close() messageBox.deleteLater() + def testInlineMessageBoxButtonsPublishClicksWithoutChangingDefaultDismissal(self): + for kind in ('text', 'standard', 'widget'): + with self.subTest(kind=kind): + messageBox = AppQMessageBox(buttons=AppQMessageBox.StandardButton.Close) + + if kind == 'standard': + button = messageBox.addButton( + AppQMessageBox.StandardButton.Help, closeOnClick=False + ) + elif kind == 'widget': + button = messageBox.addButton( + QToolButton(), + AppQMessageBox.ButtonRole.ActionRole, + closeOnClick=False, + ) + else: + button = messageBox.addButton( + 'Inline action', + AppQMessageBox.ButtonRole.ActionRole, + closeOnClick=False, + ) + + clicked = QSignalSpy(messageBox.buttonClicked) + finished = QSignalSpy(messageBox.finished) + destroyed = QSignalSpy(messageBox.destroyed) + messageBox.open() + processQtEvents() + + button.click() + self.assertEqual(clicked.count(), 1) + self.assertEqual(finished.count(), 0) + self.assertTrue(messageBox.isVisible()) + + messageBox.button(AppQMessageBox.StandardButton.Close).click() + processQtEvents() + self.assertEqual(finished.count(), 1) + self.assertEqual(destroyed.count(), 1) + + def testConnectionErrorCopyKeepsDialogOpenAndNavigationReleasesIt(self): + app = application() + originalClipboard = app.clipboard().text() + window = QWidget() + window.showLogPage = mock.Mock() + error = ConnectionError( + 'Unable to connect', 'Failed to start', 'Native detail ' + ) + + try: + with mock.patch.object(app, 'mainWindow', window, create=True): + for _index in range(30): + messageBox = ConnectionErrorMessageBox(error) + reference = weakref.ref(messageBox) + destroyed = QSignalSpy(messageBox.destroyed) + messageBox.open() + processQtEvents() + + messageBox.copyErrorButton.click() + self.assertEqual( + app.clipboard().text(), + '\n\n'.join((error.title, error.message, error.details)), + ) + self.assertTrue(messageBox.isVisible()) + self.assertEqual(messageBox.informativeText(), error.details) + self.assertEqual( + messageBox.informativeLabel.textFormat(), QtCore.Qt.PlainText + ) + + messageBox.openLogsButton.click() + processQtEvents() + + self.assertEqual(destroyed.count(), 1) + self.assertTrue(window.isVisible()) + del destroyed, messageBox + self.assertIsNone(reference()) + + self.assertEqual(window.showLogPage.call_count, 30) + finally: + app.clipboard().setText(originalClipboard) + window.close() + window.deleteLater() + + def testConnectionErrorWithoutWindowStillCopiesAndClosesNormally(self): + app = application() + originalClipboard = app.clipboard().text() + + try: + with mock.patch.object(app, 'mainWindow', None, create=True): + messageBox = ConnectionErrorMessageBox( + ConnectionError('Error', 'Failed') + ) + messageBox.open() + processQtEvents() + + self.assertFalse(messageBox.openLogsButton.isEnabled()) + messageBox.copyErrorButton.click() + self.assertEqual(app.clipboard().text(), 'Error\n\nFailed') + self.assertTrue(messageBox.isVisible()) + + QTest.keyClick(messageBox, QtCore.Qt.Key.Key_Escape) + processQtEvents() + finally: + app.clipboard().setText(originalClipboard) + + def testOpeningLogsToleratesWindowDestructionDuringDialogCompletion(self): + app = application() + window = QWidget() + window.showLogPage = mock.Mock() + + with mock.patch.object(app, 'mainWindow', window, create=True): + messageBox = ConnectionErrorMessageBox(ConnectionError('Error', 'Failed')) + messageBox.finished.connect(lambda _result: deleteQObject(window)) + messageBox.open() + processQtEvents() + + with mock.patch('sys.excepthook') as exceptionHook: + messageBox.openLogsButton.click() + processQtEvents() + exceptionHook.assert_not_called() + + self.assertFalse(isValid(window)) + window.showLogPage.assert_not_called() + def testMessageBoxHeadingSupportsLongContentIconsAndThemes(self): """Keep the semantic stack responsive across themes and icon variants.""" app = application()