From e1e336af491cd12a44d3d397c536efd48136feee Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Fri, 28 Aug 2026 07:33:15 +0800 Subject: [PATCH] fix: confirm exit when system tray is unavailable Signed-off-by: Loren Eteval --- Furious/Application/AGENTS.md | 3 +- Furious/Application/DesktopApplication.py | 75 +++++++++---- Furious/Externals/GenTranslation.py | 8 ++ tests/test_architecture_refactors.py | 129 +++++++++++++++++++++- 4 files changed, 191 insertions(+), 24 deletions(-) diff --git a/Furious/Application/AGENTS.md b/Furious/Application/AGENTS.md index 66bceec..c11c526 100644 --- a/Furious/Application/AGENTS.md +++ b/Furious/Application/AGENTS.md @@ -23,7 +23,8 @@ from the OS name alone. - `MainWindow` owns the persistent page tree through Qt parentage. The application owns top-level window/tray wrappers; the tray owns its long-lived actions and menus. Dynamic rebuilds dispose obsolete objects, and an unavailable tray - falls back to showing the main window and quitting on the last window. + falls back to showing the main window and explicitly confirms a main-window exit. Qt automatic last-window quitting + remains disabled so every accepted shutdown uses the normal application exit path. ## Verification diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 0fac702..195a36b 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -40,6 +40,7 @@ from Furious.Frozenlib import ( AppSettings, ApplicationTheme, Mixins, + PySide6Legacy, SystemProxy, SystemRuntime, Win32Session, @@ -57,7 +58,7 @@ from Furious.Controllers import ( ) from Furious.Extensions import BUNDLED_EXTENSION_TYPES from Furious.Plugins import initializePluginRegistry -from Furious.Qt import AppStyleSheet +from Furious.Qt import AppQMessageBox, AppStyleSheet from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Repository import Storage @@ -697,8 +698,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): def _initializeSystemIntegration(self): """Install operating-system session and automatic proxy integration.""" - self.setQuitOnLastWindowClosed(False) - if Win32Session.set(self._sessionShutdownRequested.emit): self._cleanupStack.register('Windows session listener', Win32Session.off) @@ -718,19 +717,45 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): 'Windows system proxy daemon', SystemProxy.daemonOff ) + def _handleMainWindowCloseEvent(self, event): + """Confirm a main-window close before Qt accepts the event.""" + assert self.systemTray is None + + mbox = AppQMessageBox( + icon=AppQMessageBox.Icon.Question, + parent=self.mainWindow, + text=_('Are you sure you want to exit the application?'), + buttons=( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ), + ) + mbox.setDefaultButton(AppQMessageBox.StandardButton.No) + + if mbox.exec() == PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + self.exit() + + # Preserve normal close-event delivery so AppQMainWindow can + # release its shown-window lifetime entry. + return False + else: + event.ignore() + + return True + def _initializeUI(self): """Create and bootstrap the application-owned main window and tray.""" try: + self.setQuitOnLastWindowClosed(False) self.applyThemePreference() + self.mainWindow = MainWindow() + self.mainWindow.installEventFilter(self) if TrayIcon.isSystemTrayAvailable(): self.systemTray = TrayIcon(parent=self) - self.setQuitOnLastWindowClosed(False) - else: - self.setQuitOnLastWindowClosed(True) - if PLATFORM == 'Darwin': if AppSettings.isStateON_('HideDockIcon'): self.installDockIconVisibilityFeature() @@ -778,6 +803,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.systemTray = None if self.mainWindow is not None: + self.mainWindow.removeEventFilter(self) self.mainWindow.hide() self.mainWindow.deleteLater() self.mainWindow = None @@ -908,29 +934,40 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): policy = 0 if visible else 1 NSApplication.sharedApplication().setActivationPolicy_(policy) - def eventFilter(self, watched, event): - # Show Dock icon on macOS when window is shown - # and hide only when window is closed (not minimized) - """Return the event filter value used by the application.""" - if PLATFORM == 'Darwin' and watched is self.mainWindow: - if event.type() == QtCore.QEvent.Type.Show: + def _filterMainWindowEvent(self, event) -> bool: + """Apply application policy before the main window handles an event.""" + eventType = event.type() + + if ( + eventType == QtCore.QEvent.Type.Close + and self.systemTray is None + and self._handleMainWindowCloseEvent(event) + ): + return True + + if PLATFORM == 'Darwin' and AppSettings.isStateON_('HideDockIcon'): + # Show the Dock icon while the main window is visible and hide it + # only when the window closes rather than when minimized. + if eventType == QtCore.QEvent.Type.Show: self.setDockIconVisible(True) - if event.type() == QtCore.QEvent.Type.Hide: - # Hide Dock icon when window is closed (not minimized) + elif eventType == QtCore.QEvent.Type.Hide: if not self.mainWindow.isMinimized(): self.setDockIconVisible(False) + return False + + def eventFilter(self, watched, event): + """Filter application-owned main-window events.""" + if watched is self.mainWindow and self._filterMainWindowEvent(event): + return True + return super().eventFilter(watched, event) def installDockIconVisibilityFeature(self, remove=False): """Handle install dock icon visibility feature for the application.""" if remove: - self.mainWindow.removeEventFilter(self) self.setDockIconVisible(True) else: - # Install event filter for main window to track show/hide - self.mainWindow.installEventFilter(self) - if not self.mainWindow.isVisible() and not self.mainWindow.isMinimized(): self.setDockIconVisible(False) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 77614b2..0322744 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -3228,5 +3228,13 @@ TRANSLATION = { "RU": "Оставьте пустым для каталога исполняемого файла", "ZH": "留空则使用可执行文件目录", "isReviewed": "True" + }, + "Are you sure you want to exit the application?": { + "source": [ + "Furious.Application.DesktopApplication" + ], + "RU": "Вы уверены, что хотите выйти из приложения?", + "ZH": "确定要退出应用程序吗?", + "isReviewed": "True" } } diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index d59545b..cf1d494 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -24,8 +24,9 @@ from Furious.Application.DesktopApplication import ( _ExistingInstanceResult, _SingletonStartupResult, ) -from Furious.Frozenlib import AppBuiltinCommand +from Furious.Frozenlib import AppBuiltinCommand, PySide6Legacy from Furious.Interface import ApplicationRunner, CoreRuntime +from Furious.Qt import AppQMessageBox from Furious.Qt.AppStyleSheet import AppStyleSheet from Furious.Service.ConnectionManager import ConnectionManager @@ -721,8 +722,8 @@ class ApplicationLifecycleTransactionTest(TestCase): outputs, ) - def testUnavailableTrayShowsMainWindowAndEnablesWindowQuit(self): - """Run normally without a desktop tray instead of rejecting Linux.""" + def testUnavailableTrayShowsMainWindowWithQtAutoQuitDisabled(self): + """Use explicit confirmed exit when the desktop tray is unavailable.""" mainWindow = mock.Mock() trayFactory = mock.Mock() trayFactory.isSystemTrayAvailable.return_value = False @@ -744,9 +745,11 @@ class ApplicationLifecycleTransactionTest(TestCase): ): DesktopApplication._initializeUI(application) - application.setQuitOnLastWindowClosed.assert_called_once_with(True) + application.setQuitOnLastWindowClosed.assert_called_once_with(False) + mainWindow.installEventFilter.assert_called_once_with(application) mainWindow.show.assert_called_once_with() self.assertIsNone(application.systemTray) + trayFactory.isSystemTrayAvailable.assert_called_once_with() trayFactory.assert_not_called() def testAvailableTrayPreservesBackgroundApplicationBehavior(self): @@ -774,10 +777,128 @@ class ApplicationLifecycleTransactionTest(TestCase): DesktopApplication._initializeUI(application) application.setQuitOnLastWindowClosed.assert_called_once_with(False) + mainWindow.installEventFilter.assert_called_once_with(application) mainWindow.show.assert_not_called() tray.show.assert_called_once_with() tray.setCustomToolTip.assert_called_once_with() tray.bootstrap.assert_called_once_with() + trayFactory.isSystemTrayAvailable.assert_called_once_with() + + def testMainWindowCloseConfirmationRequestsNormalExit(self): + """Deliver the close event after the user confirms normal shutdown.""" + messageBox = mock.Mock() + messageBox.exec.return_value = PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ) + messageBoxType = mock.Mock(return_value=messageBox) + messageBoxType.Icon = AppQMessageBox.Icon + messageBoxType.StandardButton = AppQMessageBox.StandardButton + mainWindow = mock.Mock() + closeEvent = mock.Mock() + application = SimpleNamespace( + mainWindow=mainWindow, + systemTray=None, + exit=mock.Mock(), + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.AppQMessageBox', + messageBoxType, + ): + filtered = DesktopApplication._handleMainWindowCloseEvent( + application, + closeEvent, + ) + + messageBoxType.assert_called_once_with( + icon=AppQMessageBox.Icon.Question, + parent=mainWindow, + text='Are you sure you want to exit the application?', + buttons=( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ), + ) + messageBox.setDefaultButton.assert_called_once_with( + AppQMessageBox.StandardButton.No + ) + application.exit.assert_called_once_with() + closeEvent.ignore.assert_not_called() + self.assertFalse(filtered) + + def testMainWindowCloseCancellationConsumesEvent(self): + """Reject the pending close before the main window disappears.""" + messageBox = mock.Mock() + messageBox.exec.return_value = PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.No + ) + messageBoxType = mock.Mock(return_value=messageBox) + messageBoxType.Icon = AppQMessageBox.Icon + messageBoxType.StandardButton = AppQMessageBox.StandardButton + mainWindow = mock.Mock() + closeEvent = mock.Mock() + application = SimpleNamespace( + mainWindow=mainWindow, + systemTray=None, + exit=mock.Mock(), + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.AppQMessageBox', + messageBoxType, + ): + filtered = DesktopApplication._handleMainWindowCloseEvent( + application, + closeEvent, + ) + + application.exit.assert_not_called() + closeEvent.ignore.assert_called_once_with() + self.assertTrue(filtered) + + def testMainWindowCloseEventIsFilteredOnlyWithoutSystemTray(self): + """Ask for confirmation only when no tray can retain the application.""" + closeEvent = mock.Mock() + closeEvent.type.return_value = QtCore.QEvent.Type.Close + + for systemTray, expectedFiltered in ((None, True), (mock.Mock(), False)): + with self.subTest(systemTray=systemTray): + application = SimpleNamespace( + systemTray=systemTray, + _handleMainWindowCloseEvent=mock.Mock(return_value=True), + ) + + with mock.patch( + 'Furious.Application.DesktopApplication.PLATFORM', + 'Linux', + ): + filtered = DesktopApplication._filterMainWindowEvent( + application, + closeEvent, + ) + + self.assertIs(filtered, expectedFiltered) + + if systemTray is None: + application._handleMainWindowCloseEvent.assert_called_once_with( + closeEvent + ) + else: + application._handleMainWindowCloseEvent.assert_not_called() + + def testUICleanupRemovesMainWindowEventFilter(self): + """Detach application policy before releasing the persistent window.""" + mainWindow = mock.Mock() + application = SimpleNamespace( + mainWindow=mainWindow, + systemTray=None, + ) + + DesktopApplication._cleanupUI(application) + + mainWindow.removeEventFilter.assert_called_once_with(application) + mainWindow.hide.assert_called_once_with() + mainWindow.deleteLater.assert_called_once_with() + self.assertIsNone(application.mainWindow) def testSuccessfulRunAcquiresEveryStageOnceAndCleansUpInReverse(self): application, calls = self._application()