diff --git a/rare/app.py b/rare/app.py index 06ca7554..bba47458 100644 --- a/rare/app.py +++ b/rare/app.py @@ -13,14 +13,19 @@ from typing import Optional import legendary import requests.exceptions from PyQt5.QtCore import QThreadPool, QTimer, QT_VERSION_STR, PYQT_VERSION_STR -from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMessageBox +from PyQt5.QtWidgets import QApplication, QMessageBox from requests import HTTPError import rare from rare.components.dialogs.launch_dialog import LaunchDialog from rare.components.main_window import MainWindow -from rare.components.tray_icon import TrayIcon -from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton +from rare.shared import ( + LegendaryCoreSingleton, + GlobalSignalsSingleton, + ArgumentsSingleton, + ApiResultsSingleton, + clear_singleton_instance +) from rare.shared.image_manager import ImageManagerSingleton from rare.utils import legendary_utils, config_helper from rare.utils.paths import cache_dir, tmp_dir @@ -54,64 +59,21 @@ def excepthook(exc_type, exc_value, exc_tb): class App(RareApp): - mainwindow: Optional[MainWindow] = None - tray_icon: Optional[QSystemTrayIcon] = None - def __init__(self, args: Namespace): super(App, self).__init__() + self.core = LegendaryCoreSingleton() self.args = ArgumentsSingleton(args) # add some options - self.window_launched = False - - # init Legendary - try: - self.core = LegendaryCoreSingleton(init=True) - except configparser.MissingSectionHeaderError as e: - logger.warning(f"Config is corrupt: {e}") - if config_path := os.environ.get("XDG_CONFIG_HOME"): - path = os.path.join(config_path, "legendary") - else: - path = os.path.expanduser("~/.config/legendary") - with open(os.path.join(path, "config.ini"), "w") as config_file: - config_file.write("[Legendary]") - self.core = LegendaryCoreSingleton(init=True) - if "Legendary" not in self.core.lgd.config.sections(): - self.core.lgd.config.add_section("Legendary") - self.core.lgd.save_config() lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) - config_helper.init_config_handler(self.core) - - # workaround if egl sync enabled, but no programdata_path - # programdata_path might be unset if logging in through the browser - if self.core.egl_sync_enabled: - if self.core.egl.programdata_path is None: - self.core.lgd.config.remove_option("Legendary", "egl_sync") - self.core.lgd.save_config() - else: - if not os.path.exists(self.core.egl.programdata_path): - self.core.lgd.config.remove_option("Legendary", "egl_sync") - self.core.lgd.save_config() - # set Application name for settings - self.launch_dialog = None + self.mainwindow: Optional[MainWindow] = None + self.launch_dialog: Optional[LaunchDialog] = None self.signals = GlobalSignalsSingleton(init=True) self.image_manager = ImageManagerSingleton(init=True) - self.signals.exit_app.connect(self.exit_app) - self.signals.send_notification.connect( - lambda title: self.tray_icon.showMessage( - self.tr("Download finished"), - self.tr("Download finished. {} is playable now").format(title), - QSystemTrayIcon.Information, - 4000, - ) - if self.settings.value("notification", True, bool) - else None - ) - # launch app self.launch_dialog = LaunchDialog(parent=None) self.launch_dialog.quit_app.connect(self.launch_dialog.close) @@ -140,12 +102,6 @@ class App(RareApp): td = abs(dt_exp - dt_now) self.timer.start(int(td.total_seconds() - 60) * 1000) - def show_mainwindow(self): - if self.window_launched: - self.mainwindow.show() - else: - self.mainwindow.show_window_centered() - def start_app(self): for igame in self.core.get_installed_list(): if not os.path.exists(igame.install_path): @@ -167,85 +123,27 @@ class App(RareApp): logger.info(f"{igame.title} needs verification") self.mainwindow = MainWindow() - self.tray_icon: TrayIcon = TrayIcon(self) - self.tray_icon.exit_action.triggered.connect(self.exit_app) - self.tray_icon.start_rare.triggered.connect(self.show_mainwindow) - self.tray_icon.activated.connect( - lambda r: self.show_mainwindow() - if r == QSystemTrayIcon.DoubleClick - else None - ) + self.mainwindow.exit_app.connect(self.exit_app) if not self.args.silent: - self.mainwindow.show_window_centered() - self.window_launched = True - - if self.args.subparser == "launch": - if self.args.app_name in [ - i.app_name for i in self.core.get_installed_list() - ]: - logger.info( - f"Launching {self.core.get_installed_game(self.args.app_name).title}" - ) - self.mainwindow.tab_widget.games_tab.game_utils.prepare_launch( - self.args.app_name - ) - else: - logger.error( - f"Could not find {self.args.app_name} in Games or it is not installed" - ) - QMessageBox.warning( - self.mainwindow, - "Warning", - self.tr( - "Could not find {} in installed games. Did you modify the shortcut? " - ).format(self.args.app_name), - ) + self.mainwindow.show() if self.args.test_start: self.exit_app(0) - def tray(self, reason): - if reason == QSystemTrayIcon.DoubleClick: - self.mainwindow.show() - logger.info("Show App") - def exit_app(self, exit_code=0): - # FIXME: Fix this with the download tab redesign - if self.mainwindow is not None: - if not self.args.offline and self.mainwindow.tab_widget.downloadTab.is_download_active: - question = QMessageBox.question( - self.mainwindow, - self.tr("Close"), - self.tr( - "There is a download active. Do you really want to exit app?" - ), - QMessageBox.Yes, - QMessageBox.No, - ) - if question == QMessageBox.No: - return - else: - # clear queue - self.mainwindow.tab_widget.downloadTab.queue_widget.update_queue([]) - self.mainwindow.tab_widget.downloadTab.stop_download() - # FIXME: End of FIXME - self.mainwindow.timer.stop() - self.mainwindow.hide() threadpool = QThreadPool.globalInstance() threadpool.waitForDone() - self.core.exit() - if self.mainwindow is not None: - self.mainwindow.close() - self.mainwindow.deleteLater() - self.mainwindow = None - if self.tray_icon is not None: - self.tray_icon.deleteLater() - self.tray_icon = None if self.timer is not None: self.timer.stop() self.timer.deleteLater() self.timer = None + if self.mainwindow is not None: + self.mainwindow.close() + self.mainwindow = None + clear_singleton_instance(self.signals) + clear_singleton_instance(self.args) + clear_singleton_instance(ApiResultsSingleton()) self.processEvents() shutil.rmtree(tmp_dir) os.makedirs(tmp_dir) @@ -284,10 +182,16 @@ def start(args): logger.info(f"Operating System: {platform.system()}") while True: + core = LegendaryCoreSingleton(init=True) + config_helper.init_config_handler(core) app = App(args) exit_code = app.exec_() # if not restart # restart app del app + core.exit() + clear_singleton_instance(core) if exit_code != -133742: break + + diff --git a/rare/components/main_window.py b/rare/components/main_window.py index 7f0c98c9..b5e50942 100644 --- a/rare/components/main_window.py +++ b/rare/components/main_window.py @@ -1,18 +1,22 @@ import os from logging import getLogger -from PyQt5.QtCore import Qt, QSettings, QTimer, QSize +from PyQt5.QtCore import Qt, QSettings, QTimer, QSize, pyqtSignal, pyqtSlot from PyQt5.QtGui import QCloseEvent, QCursor -from PyQt5.QtWidgets import QMainWindow, QApplication, QStatusBar, QScrollArea, QScroller, QComboBox +from PyQt5.QtWidgets import QMainWindow, QApplication, QStatusBar, QScrollArea, QScroller, QComboBox, QMessageBox from rare.components.tabs import TabWidget +from rare.components.tray_icon import TrayIcon from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton from rare.utils.paths import data_dir -logger = getLogger("Window") +logger = getLogger("MainWindow") class MainWindow(QMainWindow): + # int: exit code + exit_app: pyqtSignal = pyqtSignal(int) + def __init__(self, parent=None): super(MainWindow, self).__init__(parent=parent) self.setAttribute(Qt.WA_DeleteOnClose) @@ -46,8 +50,42 @@ class MainWindow(QMainWindow): self.timer.timeout.connect(self.timer_finished) self.timer.start(1000) - def show_window_centered(self): - self.show() + self.signals.exit_app.connect(self.on_exit_app) + self.exit_code = 0 + self.accept_close = False + + self.tray_icon: TrayIcon = TrayIcon(self) + self.tray_icon.exit_action.triggered.connect(self.on_exit_app) + self.tray_icon.start_rare.triggered.connect(self.show) + self.tray_icon.activated.connect( + lambda r: self.toggle() + if r == self.tray_icon.DoubleClick + else None + ) + + self.signals.send_notification.connect( + lambda title: self.tray_icon.showMessage( + self.tr("Download finished"), + self.tr("Download finished. {} is playable now").format(title), + self.tray_icon.Information, + 4000, + ) + if self.settings.value("notification", True, bool) + else None + ) + + self.window_launched = False + + # enable kinetic scrolling + for scroll_area in self.findChildren(QScrollArea): + if not scroll_area.property("no_kinetic_scroll"): + QScroller.grabGesture(scroll_area.viewport(), QScroller.LeftMouseButtonGesture) + + # fix scrolling + for combo_box in scroll_area.findChildren(QComboBox): + combo_box.wheelEvent = lambda e: e.ignore() + + def center_window(self): # get the margins of the decorated window margins = self.windowHandle().frameMargins() # get the screen the cursor is on @@ -68,14 +106,23 @@ class MainWindow(QMainWindow): - self.rect().adjusted(0, 0, decor_width, decor_height).center() ) - # enable kinetic scrolling - for scroll_area in self.findChildren(QScrollArea): - if not scroll_area.property("no_kinetic_scroll"): - QScroller.grabGesture(scroll_area.viewport(), QScroller.LeftMouseButtonGesture) + def show(self) -> None: + super(MainWindow, self).show() + if not self.window_launched: + self.center_window() + self.window_launched = True - # fix scrolling - for combo_box in scroll_area.findChildren(QComboBox): - combo_box.wheelEvent = lambda e: e.ignore() + def hide(self) -> None: + if self.settings.value("save_size", False, bool): + size = self.size().width(), self.size().height() + self.settings.setValue("window_size", size) + super(MainWindow, self).hide() + + def toggle(self): + if self.isHidden(): + self.show() + else: + self.hide() def timer_finished(self): file_path = os.path.join(data_dir, "lockfile") @@ -88,15 +135,43 @@ class MainWindow(QMainWindow): os.remove(file_path) self.timer.start(1000) + @pyqtSlot() + @pyqtSlot(int) + def on_exit_app(self, exit_code=0) -> None: + # FIXME: Fix this with the download tab redesign + if not self.args.offline and self.tab_widget.downloadTab.is_download_active: + question = QMessageBox.question( + self, + self.tr("Close"), + self.tr( + "There is a download active. Do you really want to exit app?" + ), + QMessageBox.Yes, + QMessageBox.No, + ) + if question == QMessageBox.No: + return + else: + # clear queue + self.tab_widget.downloadTab.queue_widget.update_queue([]) + self.tab_widget.downloadTab.stop_download() + # FIXME: End of FIXME + self.exit_code = exit_code + self.close() + + def close(self) -> bool: + self.accept_close = True + return super(MainWindow, self).close() + def closeEvent(self, e: QCloseEvent): - if self.settings.value("save_size", False, bool): - size = self.size().width(), self.size().height() - self.settings.setValue("window_size", size) - if self.settings.value("sys_tray", True, bool): - self.hide() - e.ignore() - return - elif self.args.offline: - pass - self.signals.exit_app.emit(0) - e.ignore() + if not self.accept_close: + if self.settings.value("sys_tray", True, bool): + self.hide() + e.ignore() + return + self.timer.stop() + self.tray_icon.deleteLater() + self.hide() + self.exit_app.emit(self.exit_code) + super(MainWindow, self).closeEvent(e) + e.accept() diff --git a/rare/components/tray_icon.py b/rare/components/tray_icon.py index c01ea4fb..8c29e30c 100644 --- a/rare/components/tray_icon.py +++ b/rare/components/tray_icon.py @@ -13,7 +13,7 @@ logger = getLogger("TrayIcon") class TrayIcon(QSystemTrayIcon): def __init__(self, parent): - super(TrayIcon, self).__init__(parent) + super(TrayIcon, self).__init__(parent=parent) self.core = LegendaryCoreSingleton() self.setIcon(QIcon(":/images/Rare.png")) @@ -33,7 +33,7 @@ class TrayIcon(QSystemTrayIcon): if len(installed := self.core.get_installed_list()) < 5: last_played = [GameMeta(i.app_name) for i in sorted(installed, key=lambda x: x.title)] elif games := sorted( - parent.mainwindow.tab_widget.games_tab.game_utils.game_meta.get_games(), + parent.tab_widget.games_tab.game_utils.game_meta.get_games(), key=lambda x: x.last_played, reverse=True): last_played: List[GameMeta] = games[0:5] else: @@ -46,7 +46,7 @@ class TrayIcon(QSystemTrayIcon): a.setProperty("app_name", game.app_name) self.game_actions.append(a) a.triggered.connect( - lambda: parent.mainwindow.tab_widget.games_tab.game_utils.prepare_launch( + lambda: parent.tab_widget.games_tab.game_utils.prepare_launch( self.sender().property("app_name")) ) diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py index 6bbbf05a..788e63fc 100644 --- a/rare/shared/__init__.py +++ b/rare/shared/__init__.py @@ -5,14 +5,18 @@ Each of the objects in this module should be instantiated ONCE and only ONCE! """ +import configparser +import logging +import os from argparse import Namespace -from typing import Optional +from typing import Optional, Union from rare.lgndr.core import LegendaryCore - from rare.models.apiresults import ApiResults from rare.models.signals import GlobalSignals +logger = logging.getLogger("Singleton") + _legendary_core_singleton: Optional[LegendaryCore] = None _global_signals_singleton: Optional[GlobalSignals] = None _arguments_singleton: Optional[Namespace] = None @@ -23,8 +27,33 @@ def LegendaryCoreSingleton(init: bool = False) -> LegendaryCore: global _legendary_core_singleton if _legendary_core_singleton is None and not init: raise RuntimeError("Uninitialized use of LegendaryCoreSingleton") - if _legendary_core_singleton is None: - _legendary_core_singleton = LegendaryCore() + if _legendary_core_singleton is not None and init: + raise RuntimeError("LegendaryCore already initialized") + if init: + try: + _legendary_core_singleton = LegendaryCore() + except configparser.MissingSectionHeaderError as e: + logger.warning(f"Config is corrupt: {e}") + if config_path := os.environ.get("XDG_CONFIG_HOME"): + path = os.path.join(config_path, "legendary") + else: + path = os.path.expanduser("~/.config/legendary") + with open(os.path.join(path, "config.ini"), "w") as config_file: + config_file.write("[Legendary]") + _legendary_core_singleton = LegendaryCore() + if "Legendary" not in _legendary_core_singleton.lgd.config.sections(): + _legendary_core_singleton.lgd.config.add_section("Legendary") + _legendary_core_singleton.lgd.save_config() + # workaround if egl sync enabled, but no programdata_path + # programdata_path might be unset if logging in through the browser + if _legendary_core_singleton.egl_sync_enabled: + if _legendary_core_singleton.egl.programdata_path is None: + _legendary_core_singleton.lgd.config.remove_option("Legendary", "egl_sync") + _legendary_core_singleton.lgd.save_config() + else: + if not os.path.exists(_legendary_core_singleton.egl.programdata_path): + _legendary_core_singleton.lgd.config.remove_option("Legendary", "egl_sync") + _legendary_core_singleton.lgd.save_config() return _legendary_core_singleton @@ -32,7 +61,9 @@ def GlobalSignalsSingleton(init: bool = False) -> GlobalSignals: global _global_signals_singleton if _global_signals_singleton is None and not init: raise RuntimeError("Uninitialized use of GlobalSignalsSingleton") - if _global_signals_singleton is None: + if _global_signals_singleton is not None and init: + raise RuntimeError("GlobalSignals already initialized") + if init: _global_signals_singleton = GlobalSignals() return _global_signals_singleton @@ -41,7 +72,9 @@ def ArgumentsSingleton(args: Namespace = None) -> Optional[Namespace]: global _arguments_singleton if _arguments_singleton is None and args is None: raise RuntimeError("Uninitialized use of ArgumentsSingleton") - if _arguments_singleton is None: + if _arguments_singleton is not None and args is not None: + raise RuntimeError("Arguments already initialized") + if args is not None: _arguments_singleton = args return _arguments_singleton @@ -50,7 +83,27 @@ def ApiResultsSingleton(res: ApiResults = None) -> Optional[ApiResults]: global _api_results_singleton if _api_results_singleton is None and res is None: raise RuntimeError("Uninitialized use of ApiResultsSingleton") - if _api_results_singleton is None: + if _api_results_singleton is not None and res is not None: + raise RuntimeError("ApiResults already initialized") + if res is not None: _api_results_singleton = res return _api_results_singleton + +def clear_singleton_instance(instance: Union[LegendaryCore, GlobalSignals, Namespace, ApiResults]): + global _legendary_core_singleton, _global_signals_singleton, _arguments_singleton, _api_results_singleton + if isinstance(instance, LegendaryCore): + del instance + _legendary_core_singleton = None + elif isinstance(instance, GlobalSignals): + instance.deleteLater() + del instance + _global_signals_singleton = None + elif isinstance(instance, Namespace): + del instance + _arguments_singleton = None + elif isinstance(instance, ApiResults): + del instance + _api_results_singleton = None + else: + raise RuntimeError(f"Instance is of unknown type \"{type(instance)}\"") diff --git a/rare/utils/extra_widgets.py b/rare/utils/extra_widgets.py index bb95e09a..a6ef5db2 100644 --- a/rare/utils/extra_widgets.py +++ b/rare/utils/extra_widgets.py @@ -54,7 +54,6 @@ class IndicatorReasons: class IndicatorLineEdit(QWidget): textChanged = pyqtSignal(str) - is_valid = False reasons = IndicatorReasons() def __init__( @@ -97,9 +96,10 @@ class IndicatorLineEdit(QWidget): layout.addWidget(self.indicator_label) if not placeholder: - _translate = QCoreApplication.translate + _translate = QCoreApplication.instance().translate self.line_edit.setPlaceholderText(_translate(self.__class__.__name__, "Default")) + self.is_valid = False self.edit_func = edit_func self.save_func = save_func self.line_edit.textChanged.connect(self.__edit) @@ -107,7 +107,7 @@ class IndicatorLineEdit(QWidget): self.line_edit.textChanged.connect(self.__save) # lk: this can be placed here to trigger __edit - # lk: it going to save the input again if it is valid which + # lk: it is going to save the input again if it is valid which # lk: is ok to do given the checks don't misbehave (they shouldn't) # lk: however it is going to edit any "understood" bad input to good input # lk: and we might not want that (but the validity check reports on the edited string) @@ -185,9 +185,6 @@ class PathEditIconProvider(QFileIconProvider): class PathEdit(IndicatorLineEdit): - completer = QCompleter() - compl_model = QFileSystemModel() - def __init__( self, path: str = "", @@ -200,6 +197,9 @@ class PathEdit(IndicatorLineEdit): horiz_policy: QSizePolicy = QSizePolicy.Expanding, parent=None, ): + self.completer = QCompleter() + self.compl_model = QFileSystemModel() + try: self.compl_model.setOptions( QFileSystemModel.DontWatchForChanges @@ -230,7 +230,7 @@ class PathEdit(IndicatorLineEdit): layout = self.layout() layout.addWidget(self.path_select) - _translate = QCoreApplication.translate + _translate = QCoreApplication.instance().translate self.path_select.setText(_translate("PathEdit", "Browse...")) self.type_filter = type_filter @@ -414,7 +414,7 @@ class SelectViewWidget(QWidget): class ImageLabel(QLabel): image = None img_size = None - name = str() + name = "" def __init__(self): super(ImageLabel, self).__init__()