mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 22:38:21 +03:00
Improve connection diagnostics
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Vendored
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+17
-4
@@ -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:
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+71
@@ -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)))
|
||||
|
||||
+125
-1
@@ -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 <tag>'
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user