mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-28 18:08:11 +03:00
fix: confirm exit when system tray is unavailable
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Vendored
+8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user