fix: confirm exit when system tray is unavailable

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-28 07:33:15 +08:00
parent a294619c61
commit e1e336af49
4 changed files with 191 additions and 24 deletions
+2 -1
View File
@@ -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
+56 -19
View File
@@ -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)
+8
View File
@@ -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"
}
}
+125 -4
View File
@@ -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()