diff --git a/rare/components/main_window.py b/rare/components/main_window.py index b26928bd..2399a6f4 100644 --- a/rare/components/main_window.py +++ b/rare/components/main_window.py @@ -16,6 +16,7 @@ from PyQt5.QtWidgets import ( QHBoxLayout, ) +from rare.models.options import options from rare.components.tabs import MainTabWidget from rare.components.tray_icon import TrayIcon from rare.shared import RareCore @@ -93,8 +94,8 @@ class MainWindow(QMainWindow): # self.status_timer.start() width, height = 1280, 720 - if self.settings.value("save_size", False, bool): - width, height = self.settings.value("window_size", (width, height), tuple) + if self.settings.value(*options.save_size): + width, height = self.settings.value(*options.window_size) self.resize(width, height) @@ -151,9 +152,9 @@ class MainWindow(QMainWindow): self._window_launched = True def hide(self) -> None: - if self.settings.value("save_size", False, bool): + if self.settings.value(*options.save_size): size = self.size().width(), self.size().height() - self.settings.setValue("window_size", size) + self.settings.setValue(options.window_size.key, size) super(MainWindow, self).hide() def toggle(self): @@ -214,7 +215,7 @@ class MainWindow(QMainWindow): # lk: `accept_close` is set to `True` by the `close()` method, overrides exiting to tray in `closeEvent()` # lk: ensures exiting instead of hiding when `close()` is called programmatically if not self.__accept_close: - if self.settings.value("sys_tray", True, bool): + if self.settings.value(*options.sys_tray): self.hide() e.ignore() return diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 0145066f..57cdec38 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -15,6 +15,7 @@ from rare.components.dialogs.uninstall_dialog import UninstallDialog from rare.lgndr.models.downloading import UIUpdate from rare.models.game import RareGame from rare.models.install import InstallOptionsModel, InstallQueueItemModel, UninstallOptionsModel +from rare.models.options import options from rare.shared import RareCore from rare.shared.workers.install_info import InstallInfoWorker from rare.shared.workers.uninstall import UninstallWorker @@ -105,9 +106,13 @@ class DownloadsTab(QWidget): def __add_update(self, update: Union[str, RareGame]): if isinstance(update, str): update = self.rcore.get_game(update) - if QSettings().value( - f"{update.app_name}/auto_update", False, bool - ) or QSettings().value("auto_update", False, bool): + + auto_update = QSettings(self).value( + f"{update.app_name}/{options.auto_update.key}", + QSettings(self).value(*options.auto_update), + options.auto_update.dtype + ) + if auto_update: self.__get_install_options( InstallOptionsModel(app_name=update.app_name, update=True, silent=True) ) diff --git a/rare/components/tabs/games/__init__.py b/rare/components/tabs/games/__init__.py index 2e183fd4..a31daecd 100644 --- a/rare/components/tabs/games/__init__.py +++ b/rare/components/tabs/games/__init__.py @@ -15,7 +15,7 @@ from rare.shared import RareCore from rare.widgets.library_layout import LibraryLayout from rare.widgets.sliding_stack import SlidingStackedWidget from .game_info import GameInfoTabs -from .game_widgets import LibraryWidgetController +from .game_widgets import LibraryWidgetController, LibraryFilter, LibraryOrder from .game_widgets.icon_game_widget import IconGameWidget from .game_widgets.list_game_widget import ListGameWidget from .head_bar import GameListHeadBar @@ -94,11 +94,11 @@ class GamesTab(QStackedWidget): self.head_bar.search_bar.textChanged.connect(self.scroll_to_top) self.head_bar.filterChanged.connect(self.filter_games) self.head_bar.filterChanged.connect(self.scroll_to_top) - self.head_bar.refresh_list.clicked.connect(self.library_controller.update_list) + self.head_bar.orderChanged.connect(self.order_games) + self.head_bar.orderChanged.connect(self.scroll_to_top) + self.head_bar.refresh_list.clicked.connect(self.library_controller.update_game_views) self.head_bar.view.toggled.connect(self.toggle_view) - self.active_filter: str = self.head_bar.filter.currentData(Qt.UserRole) - # signals self.signals.game.installed.connect(self.update_count_games_label) self.signals.game.uninstalled.connect(self.update_count_games_label) @@ -157,7 +157,7 @@ class GamesTab(QStackedWidget): continue self.icon_view.layout().addWidget(icon_widget) self.list_view.layout().addWidget(list_widget) - self.filter_games(self.active_filter) + self.filter_games(self.head_bar.current_filter()) self.update_count_games_label() def add_library_widget(self, rgame: RareGame): @@ -170,18 +170,26 @@ class GamesTab(QStackedWidget): list_widget.show_info.connect(self.show_game_info) return icon_widget, list_widget - @pyqtSlot(str) - @pyqtSlot(str, str) - def filter_games(self, filter_name="all", search_text: str = ""): + @pyqtSlot(int) + @pyqtSlot(int, str) + def filter_games(self, library_filter: LibraryFilter = LibraryFilter.ALL, search_text: str = ""): if not search_text and (t := self.head_bar.search_bar.text()): search_text = t - if filter_name: - self.active_filter = filter_name - if not filter_name and (t := self.active_filter): - filter_name = t + # if library_filter: + # self.active_filter = filter_type + # if not library_filter and (t := self.active_filter): + # library_filter = t - self.library_controller.filter_list(filter_name, search_text.lower()) + self.library_controller.filter_game_views(library_filter, search_text.lower()) + + @pyqtSlot(int) + @pyqtSlot(int, str) + def order_games(self, library_order: LibraryOrder = LibraryFilter.ALL, search_text: str = ""): + if not search_text and (t := self.head_bar.search_bar.text()): + search_text = t + + self.library_controller.order_game_views(library_order, search_text.lower()) def toggle_view(self): self.settings.setValue("icon_view", not self.head_bar.view.isChecked()) diff --git a/rare/components/tabs/games/game_info/cloud_saves.py b/rare/components/tabs/games/game_info/cloud_saves.py index 7006eb6a..72241692 100644 --- a/rare/components/tabs/games/game_info/cloud_saves.py +++ b/rare/components/tabs/games/game_info/cloud_saves.py @@ -3,7 +3,7 @@ import platform from logging import getLogger from typing import Tuple -from PyQt5.QtCore import QThreadPool, QSettings +from PyQt5.QtCore import QThreadPool, QSettings, pyqtSlot from PyQt5.QtWidgets import ( QWidget, QFileDialog, @@ -19,10 +19,11 @@ from legendary.models.game import SaveGameStatus from rare.models.game import RareGame from rare.shared import RareCore -from rare.shared.workers.wine_resolver import WineResolver +from rare.shared.workers.wine_resolver import WineSavePathResolver from rare.ui.components.tabs.games.game_info.cloud_settings_widget import Ui_CloudSettingsWidget from rare.ui.components.tabs.games.game_info.cloud_sync_widget import Ui_CloudSyncWidget from rare.utils.misc import icon +from rare.utils.metrics import timelogger from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon from rare.widgets.loading_widget import LoadingWidget from rare.widgets.side_tab import SideTabContents @@ -114,13 +115,14 @@ class CloudSaves(QWidget, SideTabContents): def compute_save_path(self): if self.rgame.is_installed and self.rgame.game.supports_cloud_saves: try: - new_path = self.core.get_save_path(self.rgame.app_name) + with timelogger(logger, "Detecting save path"): + new_path = self.core.get_save_path(self.rgame.app_name) if platform.system() != "Windows" and not os.path.exists(new_path): raise ValueError(f'Path "{new_path}" does not exist.') except Exception as e: logger.warning(str(e)) - resolver = WineResolver(self.core, self.rgame.raw_save_path, self.rgame.app_name) - if not resolver.wine_env.get("WINEPREFIX"): + resolver = WineSavePathResolver(self.core, self.rgame) + if not resolver.environment.get("WINEPREFIX"): del resolver self.cloud_save_path_edit.setText("") QMessageBox.warning(self, "Warning", "No wine prefix selected. Please set it in settings") @@ -129,14 +131,14 @@ class CloudSaves(QWidget, SideTabContents): self.cloud_save_path_edit.setDisabled(True) self.compute_save_path_button.setDisabled(True) - app_name = self.rgame.app_name - resolver.signals.result_ready.connect(lambda x: self.wine_resolver_finished(x, app_name)) + resolver.signals.result_ready.connect(self.__on_wine_resolver_result) QThreadPool.globalInstance().start(resolver) return else: self.cloud_save_path_edit.setText(new_path) - def wine_resolver_finished(self, path, app_name): + @pyqtSlot(str, str) + def __on_wine_resolver_result(self, path, app_name): logger.info(f"Wine resolver finished for {app_name}. Computed save path: {path}") if app_name == self.rgame.app_name: self.cloud_save_path_edit.setDisabled(False) @@ -158,8 +160,6 @@ class CloudSaves(QWidget, SideTabContents): self.cloud_save_path_edit.setText("") return self.cloud_save_path_edit.setText(path) - elif path: - self.rcore.get_game(app_name).save_path = path def __update_widget(self): supports_saves = self.rgame.igame is not None and ( diff --git a/rare/components/tabs/games/game_info/game_settings.py b/rare/components/tabs/games/game_info/game_settings.py index 702ba666..c5e49a19 100644 --- a/rare/components/tabs/games/game_info/game_settings.py +++ b/rare/components/tabs/games/game_info/game_settings.py @@ -4,7 +4,7 @@ from logging import getLogger from typing import Tuple from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QLabel, QFileDialog +from PyQt5.QtWidgets import QLabel, QFileDialog, QFormLayout from legendary.models.game import Game, InstalledGame from rare.components.tabs.settings import DefaultGameSettings @@ -20,10 +20,6 @@ logger = getLogger("GameSettings") class GameSettings(DefaultGameSettings, SideTabContents): def __init__(self, parent=None): super(GameSettings, self).__init__(False, parent=parent) - self.pre_launch_settings = PreLaunchSettings() - self.ui.launch_settings_group.layout().addRow( - QLabel(self.tr("Pre-launch command")), self.pre_launch_settings - ) self.ui.skip_update.currentIndexChanged.connect( lambda x: self.update_combobox("skip_update_check", x) @@ -43,9 +39,17 @@ class GameSettings(DefaultGameSettings, SideTabContents): save_func=self.override_exe_save_callback, parent=self ) - self.ui.launch_settings_layout.insertRow( - self.ui.launch_settings_layout.getWidgetPosition(self.ui.launch_params)[0] + 1, - QLabel(self.tr("Override executable"), self), self.override_exe_edit + self.ui.launch_settings_layout.setWidget( + self.ui.launch_settings_layout.getWidgetPosition(self.ui.override_exe_label)[0], + QFormLayout.FieldRole, + self.override_exe_edit + ) + + self.pre_launch_settings = PreLaunchSettings(parent=self) + self.ui.launch_settings_layout.setWidget( + self.ui.launch_settings_layout.getWidgetPosition(self.ui.pre_launch_label)[0], + QFormLayout.FieldRole, + self.pre_launch_settings ) self.ui.game_settings_layout.setAlignment(Qt.AlignTop) @@ -126,9 +130,9 @@ class GameSettings(DefaultGameSettings, SideTabContents): self.set_title.emit(self.game.app_title) if platform.system() != "Windows": if self.igame and self.igame.platform == "Mac": - self.ui.linux_settings_widget.setVisible(False) + self.linux_settings.setVisible(False) else: - self.ui.linux_settings_widget.setVisible(True) + self.linux_settings.setVisible(True) self.ui.launch_params.setText(self.core.lgd.config.get(self.game.app_name, "start_params", fallback="")) self.override_exe_edit.setText( diff --git a/rare/components/tabs/games/game_widgets/__init__.py b/rare/components/tabs/games/game_widgets/__init__.py index a5d5b58b..b97eb453 100644 --- a/rare/components/tabs/games/game_widgets/__init__.py +++ b/rare/components/tabs/games/game_widgets/__init__.py @@ -1,4 +1,4 @@ -from typing import Tuple, List, Union, Optional +from typing import Tuple, List, Union from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtWidgets import QWidget @@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QWidget from rare.lgndr.core import LegendaryCore from rare.models.game import RareGame from rare.models.signals import GlobalSignals +from rare.models.library import LibraryFilter, LibraryOrder from rare.shared import RareCore from .icon_game_widget import IconGameWidget from .list_game_widget import ListGameWidget @@ -20,8 +21,8 @@ class LibraryWidgetController(QObject): self.core: LegendaryCore = self.rcore.core() self.signals: GlobalSignals = self.rcore.signals() - self.signals.game.installed.connect(self.sort_list) - self.signals.game.uninstalled.connect(self.sort_list) + self.signals.game.installed.connect(self.order_game_views) + self.signals.game.uninstalled.connect(self.order_game_views) def add_game(self, rgame: RareGame): return self.add_widgets(rgame) @@ -32,24 +33,26 @@ class LibraryWidgetController(QObject): return icon_widget, list_widget @staticmethod - def __visibility(widget: Union[IconGameWidget,ListGameWidget], filter_name, search_text) -> Tuple[bool, float]: - if filter_name == "hidden": + def __visibility( + widget: Union[IconGameWidget, ListGameWidget], library_filter, search_text + ) -> Tuple[bool, float]: + if library_filter == LibraryFilter.HIDDEN: visible = "hidden" in widget.rgame.metadata.tags elif "hidden" in widget.rgame.metadata.tags: visible = False - elif filter_name == "installed": + elif library_filter == LibraryFilter.INSTALLED: visible = widget.rgame.is_installed and not widget.rgame.is_unreal - elif filter_name == "offline": + elif library_filter == LibraryFilter.OFFLINE: visible = widget.rgame.can_run_offline and not widget.rgame.is_unreal - elif filter_name == "32bit": + elif library_filter == LibraryFilter.WIN32: visible = widget.rgame.is_win32 and not widget.rgame.is_unreal - elif filter_name == "mac": + elif library_filter == LibraryFilter.MAC: visible = widget.rgame.is_mac and not widget.rgame.is_unreal - elif filter_name == "installable": + elif library_filter == LibraryFilter.INSTALLABLE: visible = not widget.rgame.is_non_asset and not widget.rgame.is_unreal - elif filter_name == "include_ue": + elif library_filter == LibraryFilter.INCLUDE_UE: visible = True - elif filter_name == "all": + elif library_filter == LibraryFilter.ALL: visible = not widget.rgame.is_unreal else: visible = True @@ -64,7 +67,7 @@ class LibraryWidgetController(QObject): return visible, opacity - def filter_list(self, filter_name="all", search_text: str = ""): + def filter_game_views(self, filter_name="all", search_text: str = ""): icon_widgets = self._icon_container.findChildren(IconGameWidget) list_widgets = self._list_container.findChildren(ListGameWidget) for iw in icon_widgets: @@ -75,42 +78,52 @@ class LibraryWidgetController(QObject): visibility, opacity = self.__visibility(lw, filter_name, search_text) lw.setOpacity(opacity) lw.setVisible(visibility) - self.sort_list(search_text) + self.order_game_views(search_text=search_text) @pyqtSlot() - def sort_list(self, sort_by: str = ""): - # lk: this is the existing sorting implemenation - # lk: it sorts by installed then by title - if sort_by: - self._icon_container.layout().sort(lambda x: (sort_by not in x.widget().rgame.app_title.lower(),)) - else: - self._icon_container.layout().sort( - key=lambda x: ( - # Sort by grant date - # x.widget().rgame.is_installed, - # not x.widget().rgame.is_non_asset, - # x.widget().rgame.grant_date(), - # ), reverse=True - not x.widget().rgame.is_installed, - x.widget().rgame.is_non_asset, - x.widget().rgame.app_title, - ) - ) + def order_game_views(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""): list_widgets = self._list_container.findChildren(ListGameWidget) - if sort_by: - list_widgets.sort(key=lambda x: (sort_by not in x.rgame.app_title.lower(),)) - else: - list_widgets.sort( - # Sort by grant date - # key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.grant_date()), reverse=True - key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.app_title) + if search_text: + self._icon_container.layout().sort( + lambda x: (search_text not in x.widget().rgame.app_title.lower(),) ) + list_widgets.sort(key=lambda x: (search_text not in x.rgame.app_title.lower(),)) + else: + if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST: + # Sort by grant date + self._icon_container.layout().sort( + key=lambda x: (x.widget().rgame.is_installed, not x.widget().rgame.is_non_asset, x.widget().rgame.grant_date()), + reverse=newest, + ) + list_widgets.sort( + key=lambda x: (x.rgame.is_installed, not x.rgame.is_non_asset, x.rgame.grant_date()), + reverse=newest, + ) + elif order_by == LibraryOrder.RECENT: + # Sort by recently played + self._icon_container.layout().sort( + key=lambda x: (not x.widget().rgame.is_installed, x.widget().rgame.is_non_asset, x.widget().rgame.metadata.last_played), + reverse=True, + ) + list_widgets.sort( + key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.metadata.last_played), + reverse=True, + ) + else: + # Sort by title + self._icon_container.layout().sort( + key=lambda x: (not x.widget().rgame.is_installed, x.widget().rgame.is_non_asset, x.widget().rgame.app_title) + ) + list_widgets.sort( + key=lambda x: (not x.rgame.is_installed, x.rgame.is_non_asset, x.rgame.app_title) + ) + for idx, wl in enumerate(list_widgets): self._list_container.layout().insertWidget(idx, wl) @pyqtSlot() @pyqtSlot(list) - def update_list(self, app_names: List[str] = None): + def update_game_views(self, app_names: List[str] = None): if not app_names: # lk: base it on icon widgets, the two lists should be identical icon_widgets = self._icon_container.findChildren(IconGameWidget) @@ -129,7 +142,7 @@ class LibraryWidgetController(QObject): game = self.rcore.get_game(app_name) lw = ListGameWidget(game) self._list_container.layout().addWidget(lw) - self.sort_list() + self.order_game_views() def __find_widget(self, app_name: str) -> Tuple[Union[IconGameWidget, None], Union[ListGameWidget, None]]: iw = self._icon_container.findChild(IconGameWidget, app_name) diff --git a/rare/components/tabs/games/head_bar.py b/rare/components/tabs/games/head_bar.py index 2311d97c..f2f583e0 100644 --- a/rare/components/tabs/games/head_bar.py +++ b/rare/components/tabs/games/head_bar.py @@ -1,22 +1,25 @@ -import platform as pf - -from PyQt5.QtCore import QSettings, pyqtSignal, pyqtSlot, Qt +from PyQt5.QtCore import QSettings, pyqtSignal, pyqtSlot, QSize, Qt from PyQt5.QtWidgets import ( QLabel, QPushButton, QWidget, QHBoxLayout, - QComboBox, QToolButton, QMenu, QAction, + QComboBox, + QToolButton, + QMenu, + QAction, ) -from qtawesome import IconWidget from rare.shared import RareCore +from rare.models.options import options from rare.utils.extra_widgets import SelectViewWidget, ButtonLineEdit from rare.utils.misc import icon +from .game_widgets import LibraryFilter, LibraryOrder class GameListHeadBar(QWidget): - filterChanged: pyqtSignal = pyqtSignal(str) + filterChanged: pyqtSignal = pyqtSignal(int) + orderChanged: pyqtSignal = pyqtSignal(int) goto_import: pyqtSignal = pyqtSignal() goto_egl_sync: pyqtSignal = pyqtSignal() goto_eos_ubisoft: pyqtSignal = pyqtSignal() @@ -27,43 +30,63 @@ class GameListHeadBar(QWidget): self.settings = QSettings(self) self.filter = QComboBox(self) - self.filter.addItem(self.tr("All games"), "all") - self.filter.addItem(self.tr("Installed"), "installed") - self.filter.addItem(self.tr("Offline"), "offline") - # self.filter.addItem(self.tr("Hidden"), "hidden") + self.filter.addItem(self.tr("All games"), LibraryFilter.ALL) + self.filter.addItem(self.tr("Installed"), LibraryFilter.INSTALLED) + self.filter.addItem(self.tr("Offline"), LibraryFilter.OFFLINE) + # self.filter.addItem(self.tr("Hidden"), LibraryFilter.HIDDEN) if self.rcore.bit32_games: - self.filter.addItem(self.tr("32bit games"), "32bit") + self.filter.addItem(self.tr("32bit games"), LibraryFilter.WIN32) if self.rcore.mac_games: - self.filter.addItem(self.tr("macOS games"), "mac") + self.filter.addItem(self.tr("macOS games"), LibraryFilter.MAC) if self.rcore.origin_games: - self.filter.addItem(self.tr("Exclude Origin"), "installable") - self.filter.addItem(self.tr("Include Unreal"), "include_ue") - - filter_default = "mac" if pf.system() == "Darwin" else "all" - filter_index = i if (i := self.filter.findData(filter_default, Qt.UserRole)) >= 0 else 0 + self.filter.addItem(self.tr("Exclude Origin"), LibraryFilter.INSTALLABLE) + self.filter.addItem(self.tr("Include Unreal"), LibraryFilter.INCLUDE_UE) try: - self.filter.setCurrentIndex(self.settings.value("library_filter", filter_index, int)) - except TypeError: - self.settings.setValue("library_filter", filter_index) - self.filter.setCurrentIndex(filter_index) - self.filter.currentIndexChanged.connect(self.filter_changed) + self.filter.setCurrentIndex(self.filter.findData( + LibraryFilter(self.settings.value(*options.library_filter)) + )) + except (TypeError, ValueError): + self.settings.setValue(options.library_filter.key, options.library_filter.default) + self.filter.setCurrentIndex(self.filter.findData(options.library_filter.default)) + self.filter.currentIndexChanged.connect(self.__filter_changed) - integrations_menu = QMenu(self) - import_action = QAction(icon("mdi.import", "fa.arrow-down"), self.tr("Import Game"), integrations_menu) + self.order = QComboBox(parent=self) + sortings = { + LibraryOrder.TITLE: self.tr("Title"), + LibraryOrder.RECENT: self.tr("Recently played"), + LibraryOrder.NEWEST: self.tr("Newest"), + LibraryOrder.OLDEST: self.tr("Oldest"), + } + for data, text in sortings.items(): + self.order.addItem(text, data) + try: + self.order.setCurrentIndex(self.order.findData( + LibraryOrder(self.settings.value(*options.library_order)) + )) + except (TypeError, ValueError): + self.settings.setValue(options.library_order.key, options.library_order.default) + self.order.setCurrentIndex(self.order.findData(options.library_order.default)) + self.order.currentIndexChanged.connect(self.__order_changed) + + integrations_menu = QMenu(parent=self) + import_action = QAction( + icon("mdi.import", "fa.arrow-down"), self.tr("Import Game"), integrations_menu + ) import_action.triggered.connect(self.goto_import) egl_sync_action = QAction(icon("mdi.sync", "fa.refresh"), self.tr("Sync with EGL"), integrations_menu) egl_sync_action.triggered.connect(self.goto_egl_sync) - eos_ubisoft_action = QAction(icon("mdi.rocket", "fa.rocket"), self.tr("Epic Overlay and Ubisoft"), - integrations_menu) + eos_ubisoft_action = QAction( + icon("mdi.rocket", "fa.rocket"), self.tr("Epic Overlay and Ubisoft"), integrations_menu + ) eos_ubisoft_action.triggered.connect(self.goto_eos_ubisoft) integrations_menu.addAction(import_action) integrations_menu.addAction(egl_sync_action) integrations_menu.addAction(eos_ubisoft_action) - integrations = QToolButton(self) + integrations = QToolButton(parent=self) integrations.setText(self.tr("Integrations")) integrations.setMenu(integrations_menu) integrations.setPopupMode(QToolButton.InstantPopup) @@ -76,8 +99,8 @@ class GameListHeadBar(QWidget): checked = QSettings().value("icon_view", True, bool) installed_tooltip = self.tr("Installed games") - self.installed_icon = IconWidget(parent=self) - self.installed_icon.setIcon(icon("ph.floppy-disk-back-fill")) + self.installed_icon = QLabel(parent=self) + self.installed_icon.setPixmap(icon("ph.floppy-disk-back-fill").pixmap(QSize(16, 16))) self.installed_icon.setToolTip(installed_tooltip) self.installed_label = QLabel(parent=self) font = self.installed_label.font() @@ -85,24 +108,25 @@ class GameListHeadBar(QWidget): self.installed_label.setFont(font) self.installed_label.setToolTip(installed_tooltip) available_tooltip = self.tr("Available games") - self.available_icon = IconWidget(parent=self) - self.available_icon.setIcon(icon("ph.floppy-disk-back-light")) + self.available_icon = QLabel(parent=self) + self.available_icon.setPixmap(icon("ph.floppy-disk-back-light").pixmap(QSize(16, 16))) self.available_icon.setToolTip(available_tooltip) self.available_label = QLabel(parent=self) self.available_label.setToolTip(available_tooltip) self.view = SelectViewWidget(checked) - self.refresh_list = QPushButton() + self.refresh_list = QPushButton(parent=self) self.refresh_list.setIcon(icon("fa.refresh")) # Reload icon - self.refresh_list.clicked.connect(self.refresh_clicked) + self.refresh_list.clicked.connect(self.__refresh_clicked) - layout = QHBoxLayout() + layout = QHBoxLayout(self) layout.setContentsMargins(0, 5, 0, 5) layout.addWidget(self.filter) + layout.addWidget(self.order) layout.addStretch(0) layout.addWidget(integrations) - layout.addStretch(5) + layout.addStretch(2) layout.addWidget(self.search_bar) layout.addStretch(2) layout.addWidget(self.installed_icon) @@ -113,17 +137,29 @@ class GameListHeadBar(QWidget): layout.addWidget(self.view) layout.addStretch(2) layout.addWidget(self.refresh_list) - self.setLayout(layout) def set_games_count(self, inst: int, avail: int) -> None: self.installed_label.setText(str(inst)) self.available_label.setText(str(avail)) @pyqtSlot() - def refresh_clicked(self): + def __refresh_clicked(self): self.rcore.fetch() + def current_filter(self) -> int: + return int(self.filter.currentData(Qt.UserRole)) + @pyqtSlot(int) - def filter_changed(self, index: int): - self.filterChanged.emit(self.filter.itemData(index, Qt.UserRole)) - self.settings.setValue("library_filter", index) + def __filter_changed(self, index: int): + data = int(self.filter.itemData(index, Qt.UserRole)) + self.filterChanged.emit(data) + self.settings.setValue(options.library_filter.key, data) + + def current_order(self) -> int: + return int(self.order.currentData(Qt.UserRole)) + + @pyqtSlot(int) + def __order_changed(self, index: int): + data = int(self.order.itemData(index, Qt.UserRole)) + self.orderChanged.emit(data) + self.settings.setValue(options.library_order.key, data) diff --git a/rare/components/tabs/games/integrations/egl_sync_group.py b/rare/components/tabs/games/integrations/egl_sync_group.py index 31ff8637..3077aaa9 100644 --- a/rare/components/tabs/games/integrations/egl_sync_group.py +++ b/rare/components/tabs/games/integrations/egl_sync_group.py @@ -13,9 +13,10 @@ from legendary.models.game import InstalledGame from rare.lgndr.glue.exception import LgndrException from rare.models.pathspec import PathSpec from rare.shared import RareCore -from rare.shared.workers.wine_resolver import WineResolver +from rare.shared.workers.wine_resolver import WinePathResolver from rare.ui.components.tabs.games.integrations.egl_sync_group import Ui_EGLSyncGroup from rare.ui.components.tabs.games.integrations.egl_sync_list_group import Ui_EGLSyncListGroup +from rare.utils import runners from rare.widgets.elide_label import ElideLabel from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon @@ -87,10 +88,10 @@ class EGLSyncGroup(QGroupBox): def __run_wine_resolver(self): self.egl_path_info.setText(self.tr("Updating...")) - wine_resolver = WineResolver( - self.core, - PathSpec.egl_programdata, - "default" + wine_resolver = WinePathResolver( + self.core.get_app_launch_command("default"), + runners.get_environment(self.core.get_app_environment("default")), + PathSpec.egl_programdata() ) wine_resolver.signals.result_ready.connect(self.__on_wine_resolver_result) QThreadPool.globalInstance().start(wine_resolver) @@ -122,14 +123,8 @@ class EGLSyncGroup(QGroupBox): os.path.join(path, "dosdevices/c:") ): # path is a wine prefix - path = os.path.join( - path, - "dosdevices/c:", - "ProgramData/Epic/EpicGamesLauncher/Data/Manifests", - ) - elif not path.rstrip("/").endswith( - "ProgramData/Epic/EpicGamesLauncher/Data/Manifests" - ): + path = PathSpec.prefix_egl_programdata(path) + elif not path.rstrip("/").endswith(PathSpec.wine_egl_programdata()): # lower() might or might not be needed in the check return False, path, IndicatorReasonsCommon.WRONG_FORMAT if os.path.exists(path): diff --git a/rare/components/tabs/settings/__init__.py b/rare/components/tabs/settings/__init__.py index c769a600..9142e426 100644 --- a/rare/components/tabs/settings/__init__.py +++ b/rare/components/tabs/settings/__init__.py @@ -1,9 +1,9 @@ -from rare.components.tabs.settings.widgets.linux import LinuxSettings +from rare.components.tabs.settings.widgets.wine import LinuxSettings from rare.shared import ArgumentsSingleton from rare.widgets.side_tab import SideTabWidget from .about import About from .debug import DebugSettings -from .game_settings import DefaultGameSettings +from .game import DefaultGameSettings from .legendary import LegendarySettings from .rare import RareSettings @@ -13,9 +13,14 @@ class SettingsTab(SideTabWidget): super(SettingsTab, self).__init__(parent=parent) self.args = ArgumentsSingleton() - self.rare_index = self.addTab(RareSettings(self), "Rare") - self.legendary_index = self.addTab(LegendarySettings(self), "Legendary") - self.settings_index = self.addTab(DefaultGameSettings(True, self), self.tr("Default Settings")) + rare_settings = RareSettings(self) + self.rare_index = self.addTab(rare_settings, "Rare") + + legendary_settings = LegendarySettings(self) + self.legendary_index = self.addTab(legendary_settings, "Legendary") + + game_settings = DefaultGameSettings(True, self) + self.settings_index = self.addTab(game_settings, self.tr("Defaults")) self.about = About(self) self.about_index = self.addTab(self.about, "About", "About") diff --git a/rare/components/tabs/settings/game.py b/rare/components/tabs/settings/game.py new file mode 100644 index 00000000..b31d5f07 --- /dev/null +++ b/rare/components/tabs/settings/game.py @@ -0,0 +1,95 @@ +import platform as pf +from logging import getLogger + +from PyQt5.QtCore import QSettings, Qt +from PyQt5.QtGui import QShowEvent +from PyQt5.QtWidgets import ( + QWidget, + QLabel, QFormLayout +) + +from components.tabs.settings.widgets.dxvk import DxvkSettings +from components.tabs.settings.widgets.mangohud import MangoHudSettings +from rare.components.tabs.settings.widgets.env_vars import EnvVars +from rare.components.tabs.settings.widgets.wine import LinuxSettings +from rare.components.tabs.settings.widgets.proton import ProtonSettings +from rare.components.tabs.settings.widgets.wrapper import WrapperSettings +from rare.shared import LegendaryCoreSingleton +from rare.ui.components.tabs.settings.game import Ui_GameSettings + +logger = getLogger("GameSettings") + + +class DefaultGameSettings(QWidget): + # variable to no update when changing game + change = False + app_name: str + + def __init__(self, is_default, parent=None): + super(DefaultGameSettings, self).__init__(parent=parent) + self.ui = Ui_GameSettings() + self.ui.setupUi(self) + self.core = LegendaryCoreSingleton() + self.settings = QSettings(self) + + self.wrapper_settings = WrapperSettings(self) + self.ui.launch_layout.setWidget( + self.ui.launch_layout.getWidgetPosition(self.ui.wrapper_label)[0], + QFormLayout.FieldRole, + self.wrapper_settings + ) + + self.env_vars = EnvVars(self) + # dxvk + self.dxvk = DxvkSettings(self) + self.dxvk.environ_changed.connect(self.env_vars.reset_model) + self.dxvk.load_settings(self.app_name) + + self.mangohud = MangoHudSettings(self) + self.mangohud.environ_changed.connect(self.environ_changed) + self.mangohud.load_settings(self.name) + + if pf.system() != "Windows": + self.linux_settings = LinuxAppSettings(self) + self.ui.game_settings_layout.addWidget(self.linux_settings) + + self.linux_settings.mangohud.set_wrapper_activated.connect( + lambda active: self.wrapper_settings.add_wrapper("mangohud") + if active else self.wrapper_settings.delete_wrapper("mangohud")) + self.linux_settings.environ_changed.connect(self.env_vars.reset_model) + + if pf.system() != "Darwin": + self.proton_settings = ProtonSettings(self.linux_settings, self.wrapper_settings) + self.linux_settings.ui.linux_settings_layout.insertWidget(0, self.proton_settings) + self.proton_settings.environ_changed.connect(self.env_vars.reset_model) + + self.ui.game_settings_layout.setAlignment(Qt.AlignTop) + + self.ui.main_layout.addWidget(self.dxvk) + self.ui.main_layout.addWidget(self.mangohud) + self.ui.main_layout.addWidget(self.env_vars) + + if is_default: + self.ui.launch_layout.removeRow(self.ui.skip_update_label) + self.ui.launch_layout.removeRow(self.ui.offline_label) + self.ui.launch_layout.removeRow(self.ui.launch_params_label) + self.ui.launch_layout.removeRow(self.ui.override_exe_label) + self.ui.launch_layout.removeRow(self.ui.pre_launch_label) + self.load_settings("default") + + def load_settings(self, app_name): + self.app_name = app_name + self.wrapper_settings.load_settings(app_name) + + if pf.system() != "Windows": + self.linux_settings.update_game(app_name) + if pf.system() != "Darwin": + proton = self.wrapper_settings.wrappers.get("proton", "") + if proton: + proton = proton.text + self.proton_settings.load_settings(app_name, proton) + else: + proton = "" + self.linux_settings.ui.wine_groupbox.setDisabled(bool(proton)) + + self.env_vars.update_game(app_name) diff --git a/rare/components/tabs/settings/game_settings.py b/rare/components/tabs/settings/game_settings.py deleted file mode 100644 index 6b96d5f6..00000000 --- a/rare/components/tabs/settings/game_settings.py +++ /dev/null @@ -1,106 +0,0 @@ -import platform -from logging import getLogger - -from PyQt5.QtCore import QSettings, Qt -from PyQt5.QtWidgets import ( - QWidget, - QLabel -) - -from rare.components.tabs.settings.widgets.env_vars import EnvVars -from rare.components.tabs.settings.widgets.wrapper import WrapperSettings -from rare.shared import LegendaryCoreSingleton -from rare.ui.components.tabs.settings.game_settings import Ui_GameSettings - -if platform.system() != "Windows": - from rare.components.tabs.settings.widgets.linux import LinuxSettings - if platform.system() != "Darwin": - from rare.components.tabs.settings.widgets.proton import ProtonSettings - -logger = getLogger("GameSettings") - - -class DefaultGameSettings(QWidget): - # variable to no update when changing game - change = False - app_name: str - - def __init__(self, is_default, parent=None): - super(DefaultGameSettings, self).__init__(parent=parent) - self.ui = Ui_GameSettings() - self.ui.setupUi(self) - self.core = LegendaryCoreSingleton() - self.settings = QSettings() - - self.wrapper_settings = WrapperSettings() - - self.ui.launch_settings_group.layout().addRow( - QLabel("Wrapper"), self.wrapper_settings - ) - - self.env_vars = EnvVars(self) - self.ui.game_settings_layout.addWidget(self.env_vars) - - if platform.system() != "Windows": - self.linux_settings = LinuxAppSettings() - if platform.system() != "Darwin": - self.proton_settings = ProtonSettings(self.linux_settings, self.wrapper_settings) - self.ui.proton_layout.addWidget(self.proton_settings) - self.proton_settings.environ_changed.connect(self.env_vars.reset_model) - - # FIXME: Remove the spacerItem and margins from the linux settings - # FIXME: This should be handled differently at soem point in the future - # NOTE: specerItem has been removed - self.linux_settings.layout().setContentsMargins(0, 0, 0, 0) - # FIXME: End of FIXME - self.ui.linux_settings_layout.addWidget(self.linux_settings) - self.ui.linux_settings_layout.setAlignment(Qt.AlignTop) - - self.ui.game_settings_layout.setAlignment(Qt.AlignTop) - - self.linux_settings.mangohud.set_wrapper_activated.connect( - lambda active: self.wrapper_settings.add_wrapper("mangohud") - if active else self.wrapper_settings.delete_wrapper("mangohud")) - self.linux_settings.environ_changed.connect(self.env_vars.reset_model) - else: - self.ui.linux_settings_widget.setVisible(False) - - if is_default: - self.ui.launch_settings_layout.removeRow(self.ui.skip_update) - self.ui.launch_settings_layout.removeRow(self.ui.offline) - self.ui.launch_settings_layout.removeRow(self.ui.launch_params) - - self.load_settings("default") - - def load_settings(self, app_name): - self.app_name = app_name - self.wrapper_settings.load_settings(app_name) - if platform.system() != "Windows": - self.linux_settings.update_game(app_name) - proton = self.wrapper_settings.wrappers.get("proton", "") - if proton: - proton = proton.text - if platform.system() != "Darwin": - self.proton_settings.load_settings(app_name, proton) - else: - proton = "" - if proton: - self.linux_settings.ui.wine_groupbox.setEnabled(False) - else: - self.linux_settings.ui.wine_groupbox.setEnabled(True) - self.env_vars.update_game(app_name) - - -if platform.system() != "Windows": - class LinuxAppSettings(LinuxSettings): - def __init__(self): - super(LinuxAppSettings, self).__init__() - - def update_game(self, app_name): - self.name = app_name - self.wine_prefix.setText(self.load_prefix()) - self.wine_exec.setText(self.load_setting(self.name, "wine_executable")) - - self.dxvk.load_settings(self.name) - - self.mangohud.load_settings(self.name) diff --git a/rare/components/tabs/settings/legendary.py b/rare/components/tabs/settings/legendary.py index 87db4c17..c88594dd 100644 --- a/rare/components/tabs/settings/legendary.py +++ b/rare/components/tabs/settings/legendary.py @@ -6,6 +6,7 @@ from typing import Tuple, List from PyQt5.QtCore import QObject, pyqtSignal, QThreadPool, QSettings from PyQt5.QtWidgets import QSizePolicy, QWidget, QFileDialog, QMessageBox +from rare.models.options import options from rare.shared import LegendaryCoreSingleton from rare.shared.workers.worker import Worker from rare.ui.components.tabs.settings.legendary import Ui_LegendarySettings @@ -100,33 +101,30 @@ class LegendarySettings(QWidget, Ui_LegendarySettings): ) self.locale_layout.addWidget(self.locale_edit) - self.fetch_win32_check.setChecked(self.settings.value("win32_meta", False, bool)) + self.fetch_win32_check.setChecked(self.settings.value(*options.win32_meta)) self.fetch_win32_check.stateChanged.connect( - lambda: self.settings.setValue("win32_meta", self.fetch_win32_check.isChecked()) + lambda: self.settings.setValue(options.win32_meta.key, self.fetch_win32_check.isChecked()) ) - self.fetch_macos_check.setChecked(self.settings.value("macos_meta", pf.system() == "Darwin", bool)) + self.fetch_macos_check.setChecked(self.settings.value(*options.macos_meta)) self.fetch_macos_check.stateChanged.connect( - lambda: self.settings.setValue("macos_meta", self.fetch_macos_check.isChecked()) + lambda: self.settings.setValue(options.macos_meta.key, self.fetch_macos_check.isChecked()) ) self.fetch_macos_check.setDisabled(pf.system() == "Darwin") - self.fetch_unreal_check.setChecked(self.settings.value("unreal_meta", False, bool)) + self.fetch_unreal_check.setChecked(self.settings.value(*options.unreal_meta)) self.fetch_unreal_check.stateChanged.connect( - lambda: self.settings.setValue("unreal_meta", self.fetch_unreal_check.isChecked()) + lambda: self.settings.setValue(options.unreal_meta.key, self.fetch_unreal_check.isChecked()) ) - self.exclude_non_asset_check.setChecked( - self.settings.value("exclude_non_asset", False, bool) - ) + self.exclude_non_asset_check.setChecked(self.settings.value(*options.exclude_non_asset)) self.exclude_non_asset_check.stateChanged.connect( - lambda: self.settings.setValue("exclude_non_asset", self.exclude_non_asset_check.isChecked()) - ) - self.exclude_entitlements_check.setChecked( - self.settings.value("exclude_entitlements", False, bool) + lambda: self.settings.setValue(options.exclude_non_asset.key, self.exclude_non_asset_check.isChecked()) ) + + self.exclude_entitlements_check.setChecked(self.settings.value(*options.exclude_entitlements)) self.exclude_entitlements_check.stateChanged.connect( - lambda: self.settings.setValue("exclude_entitlements", self.exclude_entitlements_check.isChecked()) + lambda: self.settings.setValue(options.exclude_entitlements.key, self.exclude_entitlements_check.isChecked()) ) self.refresh_metadata_button.clicked.connect(self.refresh_metadata) diff --git a/rare/components/tabs/settings/rare.py b/rare/components/tabs/settings/rare.py index 1c42040a..b366ba38 100644 --- a/rare/components/tabs/settings/rare.py +++ b/rare/components/tabs/settings/rare.py @@ -8,6 +8,7 @@ from PyQt5.QtCore import QSettings, Qt from PyQt5.QtWidgets import QWidget, QMessageBox from rare.components.tabs.settings.widgets.rpc import RPCSettings +from rare.models.options import options from rare.shared import LegendaryCoreSingleton from rare.ui.components.tabs.settings.rare import Ui_RareSettings from rare.utils.misc import ( @@ -39,18 +40,8 @@ class RareSettings(QWidget, Ui_RareSettings): super(RareSettings, self).__init__(parent=parent) self.setupUi(self) self.core = LegendaryCoreSingleton() - # (widget_name, option_name, default) - self.checkboxes = [ - (self.sys_tray, "sys_tray", True), - (self.auto_update, "auto_update", False), - (self.confirm_start, "confirm_start", False), - (self.auto_sync_cloud, "auto_sync_cloud", False), - (self.notification, "notification", True), - (self.save_size, "save_size", False), - (self.log_games, "show_console", False), - ] + self.settings = QSettings(self) - self.settings = QSettings() language = self.settings.value("language", self.core.language_code, type=str) # Select lang @@ -85,29 +76,37 @@ class RareSettings(QWidget, Ui_RareSettings): self.rpc = RPCSettings(self) self.right_layout.insertWidget(1, self.rpc, alignment=Qt.AlignTop) - self.init_checkboxes(self.checkboxes) + self.sys_tray.setChecked(self.settings.value(*options.sys_tray)) self.sys_tray.stateChanged.connect( - lambda: self.settings.setValue("sys_tray", self.sys_tray.isChecked()) + lambda: self.settings.setValue(options.sys_tray.key, self.sys_tray.isChecked()) ) + + self.auto_update.setChecked(self.settings.value(*options.auto_update)) self.auto_update.stateChanged.connect( - lambda: self.settings.setValue("auto_update", self.auto_update.isChecked()) + lambda: self.settings.setValue(options.auto_update.key, self.auto_update.isChecked()) ) + + self.confirm_start.setChecked(self.settings.value(*options.confirm_start)) self.confirm_start.stateChanged.connect( - lambda: self.settings.setValue( - "confirm_start", self.confirm_start.isChecked() - ) + lambda: self.settings.setValue(options.confirm_start.key, self.confirm_start.isChecked()) ) + + self.auto_sync_cloud.setChecked(self.settings.value(*options.auto_sync_cloud)) self.auto_sync_cloud.stateChanged.connect( - lambda: self.settings.setValue( - "auto_sync_cloud", self.auto_sync_cloud.isChecked() - ) + lambda: self.settings.setValue(options.auto_sync_cloud.key, self.auto_sync_cloud.isChecked()) ) + + self.notification.setChecked(self.settings.value(*options.notification)) self.notification.stateChanged.connect( - lambda: self.settings.setValue("notification", self.notification.isChecked()) + lambda: self.settings.setValue(options.notification.key, self.notification.isChecked()) ) + + self.save_size.setChecked(self.settings.value(*options.save_size)) self.save_size.stateChanged.connect(self.save_window_size) + + self.log_games.setChecked(self.settings.value(*options.log_games)) self.log_games.stateChanged.connect( - lambda: self.settings.setValue("show_console", self.log_games.isChecked()) + lambda: self.settings.setValue(options.log_games.key, self.log_games.isChecked()) ) if desktop_links_supported(): @@ -221,13 +220,8 @@ class RareSettings(QWidget, Ui_RareSettings): subprocess.Popen([opener, log_dir()]) def save_window_size(self): - self.settings.setValue("save_size", self.save_size.isChecked()) - self.settings.remove("window_size") + self.settings.setValue(options.save_size.key, self.save_size.isChecked()) + self.settings.remove(options.window_size.key) def update_lang(self, i: int): self.settings.setValue("language", languages[i][0]) - - def init_checkboxes(self, checkboxes): - for cb in checkboxes: - widget, option, default = cb - widget.setChecked(self.settings.value(option, default, bool)) diff --git a/rare/components/tabs/settings/widgets/dxvk.py b/rare/components/tabs/settings/widgets/dxvk.py index 2d3e0d56..372b0bac 100644 --- a/rare/components/tabs/settings/widgets/dxvk.py +++ b/rare/components/tabs/settings/widgets/dxvk.py @@ -1,10 +1,10 @@ from PyQt5.QtCore import QCoreApplication -from .overlay_settings import OverlaySettings, CustomOption +from .overlays import OverlaySettings, CustomOption class DxvkSettings(OverlaySettings): - def __init__(self): + def __init__(self, parent=None): super(DxvkSettings, self).__init__( [ ("fps", QCoreApplication.translate("DxvkSettings", "FPS")), @@ -19,7 +19,8 @@ class DxvkSettings(OverlaySettings): [ (CustomOption.number_input("scale", 1, True), QCoreApplication.translate("DxvkSettings", "Scale")) ], - "DXVK_HUD", "0" + "DXVK_HUD", "0", + parent=parent ) self.setTitle(self.tr("DXVK Settings")) diff --git a/rare/components/tabs/settings/widgets/env_vars_model.py b/rare/components/tabs/settings/widgets/env_vars_model.py index 08ba8a9c..f8b7364b 100644 --- a/rare/components/tabs/settings/widgets/env_vars_model.py +++ b/rare/components/tabs/settings/widgets/env_vars_model.py @@ -11,12 +11,14 @@ from rare.lgndr.core import LegendaryCore from rare.utils.misc import icon if platform.system() != "Windows": + from rare.utils.runners.wine import get_wine_environment if platform.system() != "Darwin": - from rare.utils import proton + from rare.utils.runners.proton import get_steam_environment + class EnvVarsTableModel(QAbstractTableModel): - def __init__(self, core: LegendaryCore, parent = None): + def __init__(self, core: LegendaryCore, parent=None): super(EnvVarsTableModel, self).__init__(parent=parent) self.core = core @@ -27,14 +29,13 @@ class EnvVarsTableModel(QAbstractTableModel): self.__data_map: ChainMap = ChainMap() self.__readonly = [ - "STEAM_COMPAT_DATA_PATH", - "WINEPREFIX", "DXVK_HUD", "MANGOHUD_CONFIG", ] if platform.system() != "Windows": + self.__readonly.extend(get_wine_environment().keys()) if platform.system() != "Darwin": - self.__readonly.extend(proton.get_steam_environment(None).keys()) + self.__readonly.extend(get_steam_environment().keys()) self.__default: str = "default" self.__appname: str = None @@ -256,8 +257,6 @@ class EnvVarsTableModel(QAbstractTableModel): if __name__ == "__main__": from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout, QTableView, QHeaderView - from rare.resources import static_css - from rare.resources.stylesheets import RareStyle from rare.utils.misc import set_style_sheet from legendary.core import LegendaryCore diff --git a/rare/components/tabs/settings/widgets/mangohud.py b/rare/components/tabs/settings/widgets/mangohud.py index e99570aa..a9394c4e 100644 --- a/rare/components/tabs/settings/widgets/mangohud.py +++ b/rare/components/tabs/settings/widgets/mangohud.py @@ -5,18 +5,17 @@ from PyQt5.QtCore import QCoreApplication, pyqtSignal from PyQt5.QtWidgets import QMessageBox from rare.shared import LegendaryCoreSingleton -from .overlay_settings import OverlaySettings, CustomOption, ActivationStates from rare.utils import config_helper +from .overlays import OverlaySettings, CustomOption, ActivationStates position_values = ["default", "top-left", "top-right", "middle-left", "middle-right", "bottom-left", "bottom-right", "top-center"] class MangoHudSettings(OverlaySettings): - set_wrapper_activated = pyqtSignal(bool) - def __init__(self): + def __init__(self, parent=None): super(MangoHudSettings, self).__init__( [ ("fps", QCoreApplication.translate("MangoSettings", "FPS")), @@ -45,7 +44,8 @@ class MangoHudSettings(OverlaySettings): QCoreApplication.translate("MangoSettings", "Position") ) ], - "MANGOHUD_CONFIG", "no_display", set_activation_state=self.set_activation_state + "MANGOHUD_CONFIG", "no_display", set_activation_state=self.set_activation_state, + parent=parent ) self.core = LegendaryCoreSingleton() self.setTitle(self.tr("MangoHud Settings")) diff --git a/rare/components/tabs/settings/widgets/overlay_settings.py b/rare/components/tabs/settings/widgets/overlays.py similarity index 98% rename from rare/components/tabs/settings/widgets/overlay_settings.py rename to rare/components/tabs/settings/widgets/overlays.py index 31c50727..5fd85bf5 100644 --- a/rare/components/tabs/settings/widgets/overlay_settings.py +++ b/rare/components/tabs/settings/widgets/overlays.py @@ -82,8 +82,8 @@ class OverlaySettings(QGroupBox, Ui_OverlaySettings): def __init__(self, checkboxes_map: List[Tuple[str, str]], value_map: List[Tuple[CustomOption, str]], config_env_var_name: str, no_display_value: str, - set_activation_state: Callable[[Enum], None] = lambda x: None): - super(OverlaySettings, self).__init__() + set_activation_state: Callable[[Enum], None] = lambda x: None, parent=None): + super(OverlaySettings, self).__init__(parent=parent) self.setupUi(self) self.core = LegendaryCoreSingleton() self.config_env_var_name = config_env_var_name diff --git a/rare/components/tabs/settings/widgets/pre_launch.py b/rare/components/tabs/settings/widgets/pre_launch.py index 006f1cee..411e9ce1 100644 --- a/rare/components/tabs/settings/widgets/pre_launch.py +++ b/rare/components/tabs/settings/widgets/pre_launch.py @@ -2,18 +2,18 @@ import os import shutil from typing import Tuple -from PyQt5.QtWidgets import QHBoxLayout, QCheckBox, QFileDialog +from PyQt5.QtWidgets import QHBoxLayout, QCheckBox, QFileDialog, QWidget from rare.shared import LegendaryCoreSingleton from rare.utils import config_helper from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon -class PreLaunchSettings(QHBoxLayout): +class PreLaunchSettings(QWidget): app_name: str - def __init__(self): - super(PreLaunchSettings, self).__init__() + def __init__(self, parent=None): + super(PreLaunchSettings, self).__init__(parent=parent) self.core = LegendaryCoreSingleton() self.edit = PathEdit( path="", @@ -22,12 +22,15 @@ class PreLaunchSettings(QHBoxLayout): edit_func=self.edit_command, save_func=self.save_pre_launch_command, ) - self.layout().addWidget(self.edit) self.wait_check = QCheckBox(self.tr("Wait for finish")) - self.layout().addWidget(self.wait_check) self.wait_check.stateChanged.connect(self.save_wait_finish) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.edit) + layout.addWidget(self.wait_check) + def edit_command(self, text: str) -> Tuple[bool, str, int]: if not text.strip(): return True, text, IndicatorReasonsCommon.VALID diff --git a/rare/components/tabs/settings/widgets/proton.py b/rare/components/tabs/settings/widgets/proton.py index 658aaf87..4505bcfe 100644 --- a/rare/components/tabs/settings/widgets/proton.py +++ b/rare/components/tabs/settings/widgets/proton.py @@ -1,85 +1,106 @@ import os from logging import getLogger -from pathlib import Path -from typing import Tuple +from typing import Tuple, Union, Optional from PyQt5.QtCore import pyqtSignal +from PyQt5.QtGui import QShowEvent from PyQt5.QtWidgets import QGroupBox, QFileDialog -from rare.components.tabs.settings import LinuxSettings -from rare.shared import LegendaryCoreSingleton +from rare.models.wrapper import Wrapper, WrapperType +from rare.shared import RareCore +from rare.shared.wrappers import Wrappers from rare.ui.components.tabs.settings.proton import Ui_ProtonSettings -from rare.utils import config_helper, proton +from rare.utils import config_helper as config +from rare.utils.runners import proton from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon -from .wrapper import WrapperSettings -logger = getLogger("Proton") +logger = getLogger("ProtonSettings") class ProtonSettings(QGroupBox): # str: option key - environ_changed = pyqtSignal(str) - app_name: str - changeable = True + environ_changed: pyqtSignal = pyqtSignal(str) + # bool: state + tool_enabled: pyqtSignal = pyqtSignal(bool) - def __init__(self, linux_settings: LinuxSettings, wrapper_settings: WrapperSettings): - super(ProtonSettings, self).__init__() + def __init__(self, parent=None): + super(ProtonSettings, self).__init__(parent=parent) self.ui = Ui_ProtonSettings() self.ui.setupUi(self) - self._linux_settings = linux_settings - self._wrapper_settings = wrapper_settings - self.core = LegendaryCoreSingleton() - self.possible_proton_combos = proton.find_proton_combos() - - self.ui.proton_combo.addItems(self.possible_proton_combos) - self.ui.proton_combo.currentIndexChanged.connect(self.change_proton) + self.ui.proton_combo.currentIndexChanged.connect(self.__on_proton_changed) self.proton_prefix = PathEdit( file_mode=QFileDialog.DirectoryOnly, edit_func=self.proton_prefix_edit, save_func=self.proton_prefix_save, - placeholder=self.tr("Please select path for proton prefix") + placeholder=self.tr("Please select path for proton prefix"), ) self.ui.prefix_layout.addWidget(self.proton_prefix) - def change_proton(self, i): - if not self.changeable: - return - # First combo box entry: Don't use Proton - if i == 0: - self._wrapper_settings.delete_wrapper("proton") - config_helper.remove_option(self.app_name, "no_wine") - config_helper.remove_option(f"{self.app_name}.env", "STEAM_COMPAT_DATA_PATH") - self.environ_changed.emit("STEAM_COMPAT_DATA_PATH") - config_helper.remove_option(f"{self.app_name}.env", "STEAM_COMPAT_CLIENT_INSTALL_PATH") - self.environ_changed.emit("STEAM_COMPAT_CLIENT_INSTALL_PATH") + self.app_name: str = "default" + self.core = RareCore.instance().core() + self.wrappers: Wrappers = RareCore.instance().wrappers() + self.tool_wrapper: Optional[Wrapper] = None - self.proton_prefix.setEnabled(False) - self.proton_prefix.setText("") - - self._linux_settings.ui.wine_groupbox.setEnabled(True) - else: - self.proton_prefix.setEnabled(True) - self._linux_settings.ui.wine_groupbox.setEnabled(False) - wrapper = self.possible_proton_combos[i - 1] - self._wrapper_settings.add_wrapper(wrapper) - config_helper.add_option(self.app_name, "no_wine", "true") - config_helper.add_option( - f"{self.app_name}.env", - "STEAM_COMPAT_CLIENT_INSTALL_PATH", - str(Path.home().joinpath(".steam", "steam")) + def showEvent(self, a0: QShowEvent) -> None: + if a0.spontaneous(): + return super().showEvent(a0) + self.ui.proton_combo.blockSignals(True) + self.ui.proton_combo.clear() + self.ui.proton_combo.addItem(self.tr("Don't use a compatibility tool"), None) + tools = proton.find_tools() + for tool in tools: + self.ui.proton_combo.addItem(tool.name, tool) + try: + wrapper = next( + filter(lambda w: w.is_compat_tool, self.wrappers.get_game_wrapper_list(self.app_name)) ) - self.environ_changed.emit("STEAM_COMPAT_CLIENT_INSTALL_PATH") + self.tool_wrapper = wrapper + tool = next(filter(lambda t: t.checksum == wrapper.checksum, tools)) + index = self.ui.proton_combo.findData(tool) + except StopIteration: + index = 0 + self.ui.proton_combo.setCurrentIndex(index) + self.ui.proton_combo.blockSignals(False) + enabled = bool(self.ui.proton_combo.currentIndex()) + self.proton_prefix.blockSignals(True) + self.proton_prefix.setText(config.get_envvar(self.app_name, "STEAM_COMPAT_DATA_PATH", fallback="")) + self.proton_prefix.setEnabled(enabled) + self.proton_prefix.blockSignals(False) + super().showEvent(a0) - self.proton_prefix.setText(os.path.expanduser("~/.proton")) + def __on_proton_changed(self, index): + steam_tool: Union[proton.ProtonTool, proton.CompatibilityTool] = self.ui.proton_combo.itemData(index) - # Don't use Wine - self._linux_settings.wine_exec.setText("") - self._linux_settings.wine_prefix.setText("") + steam_environ = proton.get_steam_environment(steam_tool) + for key, value in steam_environ.items(): + if not value: + config.remove_envvar(self.app_name, key) + else: + config.add_envvar(self.app_name, key, value) + self.environ_changed.emit(key) - config_helper.save_config() + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + if self.tool_wrapper and self.tool_wrapper in wrappers: + wrappers.remove(self.tool_wrapper) + if steam_tool is None: + self.tool_wrapper = None + else: + wrapper = Wrapper( + command=steam_tool.command(), name=steam_tool.name, wtype=WrapperType.COMPAT_TOOL + ) + wrappers.append(wrapper) + self.tool_wrapper = wrapper + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) - def proton_prefix_edit(self, text: str) -> Tuple[bool, str, int]: + self.proton_prefix.setEnabled(steam_tool is not None) + self.proton_prefix.setText(os.path.expanduser("~/.proton") if steam_tool is not None else "") + + self.tool_enabled.emit(steam_tool is not None) + config.save_config() + + @staticmethod + def proton_prefix_edit(text: str) -> Tuple[bool, str, int]: if not text: return False, text, IndicatorReasonsCommon.EMPTY parent_dir = os.path.dirname(text) @@ -88,28 +109,9 @@ class ProtonSettings(QGroupBox): def proton_prefix_save(self, text: str): if not text: return - config_helper.add_option( - f"{self.app_name}.env", "STEAM_COMPAT_DATA_PATH", text - ) + config.add_envvar(self.app_name, "STEAM_COMPAT_DATA_PATH", text) self.environ_changed.emit("STEAM_COMPAT_DATA_PATH") - config_helper.save_config() + config.save_config() - def load_settings(self, app_name: str, proton: str): - self.changeable = False + def load_settings(self, app_name: str): self.app_name = app_name - proton = proton.replace('"', "") - self.proton_prefix.setEnabled(bool(proton)) - if proton: - self.ui.proton_combo.setCurrentText( - f'"{proton.replace(" run", "")}" run' - ) - else: - self.ui.proton_combo.setCurrentIndex(0) - - proton_prefix = self.core.lgd.config.get( - f"{app_name}.env", - "STEAM_COMPAT_DATA_PATH", - fallback="", - ) - self.proton_prefix.setText(proton_prefix) - self.changeable = True diff --git a/rare/components/tabs/settings/widgets/rpc.py b/rare/components/tabs/settings/widgets/rpc.py index 3d2ad312..e3a3a7e5 100644 --- a/rare/components/tabs/settings/widgets/rpc.py +++ b/rare/components/tabs/settings/widgets/rpc.py @@ -2,6 +2,7 @@ from PyQt5.QtCore import QSettings from PyQt5.QtWidgets import QGroupBox from rare.shared import GlobalSignalsSingleton +from rare.models.options import options from rare.ui.components.tabs.settings.widgets.rpc import Ui_RPCSettings @@ -13,22 +14,22 @@ class RPCSettings(QGroupBox, Ui_RPCSettings): self.settings = QSettings() - self.enable.setCurrentIndex(self.settings.value("rpc_enable", 0, int)) - self.enable.currentIndexChanged.connect(self.changed) + self.enable.setCurrentIndex(self.settings.value(*options.rpc_enable)) + self.enable.currentIndexChanged.connect(self.__enable_changed) - self.show_game.setChecked((self.settings.value("rpc_name", True, bool))) + self.show_game.setChecked((self.settings.value(*options.rpc_name))) self.show_game.stateChanged.connect( - lambda: self.settings.setValue("rpc_game", self.show_game.isChecked()) + lambda: self.settings.setValue(options.rpc_name.key, self.show_game.isChecked()) ) - self.show_os.setChecked((self.settings.value("rpc_os", True, bool))) + self.show_os.setChecked((self.settings.value(*options.rpc_os))) self.show_os.stateChanged.connect( - lambda: self.settings.setValue("rpc_os", self.show_os.isChecked()) + lambda: self.settings.setValue(options.rpc_os.key, self.show_os.isChecked()) ) - self.show_time.setChecked((self.settings.value("rpc_time", True, bool))) + self.show_time.setChecked((self.settings.value(*options.rpc_time))) self.show_time.stateChanged.connect( - lambda: self.settings.setValue("rpc_time", self.show_time.isChecked()) + lambda: self.settings.setValue(options.rpc_time.key, self.show_time.isChecked()) ) try: @@ -37,6 +38,6 @@ class RPCSettings(QGroupBox, Ui_RPCSettings): self.setDisabled(True) self.setToolTip(self.tr("Pypresence is not installed")) - def changed(self, i): - self.settings.setValue("rpc_enable", i) + def __enable_changed(self, i): + self.settings.setValue(options.rpc_enable.key, i) self.signals.discord_rpc.apply_settings.emit() diff --git a/rare/components/tabs/settings/widgets/unix.py b/rare/components/tabs/settings/widgets/unix.py new file mode 100644 index 00000000..28c9f21a --- /dev/null +++ b/rare/components/tabs/settings/widgets/unix.py @@ -0,0 +1,15 @@ +from components.tabs.settings import LinuxSettings + + +class LinuxAppSettings(LinuxSettings): + def __init__(self, parent=None): + super(LinuxAppSettings, self).__init__(parent=parent) + + def update_game(self, app_name): + self.name = app_name + self.wine_prefix.setText(self.load_prefix()) + self.wine_exec.setText(self.load_setting(self.name, "wine_executable")) + + self.dxvk.load_settings(self.name) + + self.mangohud.load_settings(self.name) \ No newline at end of file diff --git a/rare/components/tabs/settings/widgets/linux.py b/rare/components/tabs/settings/widgets/wine.py similarity index 58% rename from rare/components/tabs/settings/widgets/linux.py rename to rare/components/tabs/settings/widgets/wine.py index 7df32196..cf7a8d27 100644 --- a/rare/components/tabs/settings/widgets/linux.py +++ b/rare/components/tabs/settings/widgets/wine.py @@ -3,31 +3,29 @@ import shutil from logging import getLogger from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QFileDialog, QWidget +from PyQt5.QtWidgets import QFileDialog, QWidget, QFormLayout, QGroupBox -from rare.components.tabs.settings.widgets.dxvk import DxvkSettings -from rare.components.tabs.settings.widgets.mangohud import MangoHudSettings from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton -from rare.ui.components.tabs.settings.linux import Ui_LinuxSettings +from rare.ui.components.tabs.settings.widgets.wine import Ui_WineSettings from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon from rare.utils import config_helper logger = getLogger("LinuxSettings") -class LinuxSettings(QWidget): +class WineSettings(QGroupBox): # str: option key environ_changed = pyqtSignal(str) def __init__(self, name=None, parent=None): - super(LinuxSettings, self).__init__(parent=parent) - self.ui = Ui_LinuxSettings() + super(WineSettings, self).__init__(parent=parent) + self.ui = Ui_WineSettings() self.ui.setupUi(self) self.core = LegendaryCoreSingleton() self.signals = GlobalSignalsSingleton() - self.name = name if name is not None else "default" + self.app_name: str = "default" # Wine prefix self.wine_prefix = PathEdit( @@ -36,49 +34,46 @@ class LinuxSettings(QWidget): edit_func=lambda path: (os.path.isdir(path) or not path, path, IndicatorReasonsCommon.DIR_NOT_EXISTS), save_func=self.save_prefix, ) - self.ui.prefix_layout.addWidget(self.wine_prefix) + self.ui.main_layout.setWidget( + self.ui.main_layout.getWidgetPosition(self.ui.prefix_label)[0], + QFormLayout.FieldRole, + self.wine_prefix + ) # Wine executable self.wine_exec = PathEdit( - self.load_setting(self.name, "wine_executable"), + self.load_setting(self.app_name, "wine_executable"), file_mode=QFileDialog.ExistingFile, name_filters=["wine", "wine64"], edit_func=lambda text: (os.path.exists(text) or not text, text, IndicatorReasonsCommon.DIR_NOT_EXISTS), save_func=lambda text: self.save_setting( - text, section=self.name, setting="wine_executable" + text, section=self.app_name, setting="wine_executable" ), ) - self.ui.exec_layout.addWidget(self.wine_exec) - - # dxvk - self.dxvk = DxvkSettings() - self.dxvk.environ_changed.connect(self.environ_changed) - self.ui.linux_layout.addWidget(self.dxvk) - self.dxvk.load_settings(self.name) - - self.mangohud = MangoHudSettings() - self.mangohud.environ_changed.connect(self.environ_changed) - self.ui.linux_layout.addWidget(self.mangohud) - self.mangohud.load_settings(self.name) - + self.ui.main_layout.setWidget( + self.ui.main_layout.getWidgetPosition(self.ui.exec_label)[0], + QFormLayout.FieldRole, + self.wine_exec + ) def load_prefix(self) -> str: return self.load_setting( - f"{self.name}.env", + f"{self.app_name}.env", "WINEPREFIX", - fallback=self.load_setting(self.name, "wine_prefix"), + fallback=self.load_setting(self.app_name, "wine_prefix"), ) def save_prefix(self, text: str): - self.save_setting(text, f"{self.name}.env", "WINEPREFIX") + self.save_setting(text, f"{self.app_name}.env", "WINEPREFIX") self.environ_changed.emit("WINEPREFIX") - self.save_setting(text, self.name, "wine_prefix") + self.save_setting(text, self.app_name, "wine_prefix") self.signals.application.prefix_updated.emit() def load_setting(self, section: str, setting: str, fallback: str = ""): return self.core.lgd.config.get(section, setting, fallback=fallback) - def save_setting(self, text: str, section: str, setting: str): + @staticmethod + def save_setting(text: str, section: str, setting: str): if text: config_helper.add_option(section, setting, text) logger.debug(f"Set {setting} in {f'[{section}]'} to {text}") diff --git a/rare/components/tabs/settings/widgets/wrapper.py b/rare/components/tabs/settings/widgets/wrapper.py index b8b35c43..def0af39 100644 --- a/rare/components/tabs/settings/widgets/wrapper.py +++ b/rare/components/tabs/settings/widgets/wrapper.py @@ -1,9 +1,9 @@ -import re +import shlex import shutil from logging import getLogger -from typing import Dict, Optional +from typing import Optional -from PyQt5.QtCore import pyqtSignal, QSettings, QSize, Qt, QMimeData, pyqtSlot, QCoreApplication +from PyQt5.QtCore import pyqtSignal, QSize, Qt, QMimeData, pyqtSlot, QCoreApplication from PyQt5.QtGui import QDrag, QDropEvent, QDragEnterEvent, QDragMoveEvent, QFont, QMouseEvent from PyQt5.QtWidgets import ( QHBoxLayout, @@ -16,46 +16,41 @@ from PyQt5.QtWidgets import ( QScrollArea, QAction, QToolButton, - QMenu, + QMenu, QDialog, ) +from rare.models.wrapper import Wrapper from rare.shared import RareCore from rare.ui.components.tabs.settings.widgets.wrapper import Ui_WrapperSettings -from rare.utils import config_helper from rare.utils.misc import icon +from rare.utils.runners import proton logger = getLogger("WrapperSettings") -extra_wrapper_regex = { - "proton": "\".*proton\" run", # proton - "mangohud": "mangohud" # mangohud -} +# extra_wrapper_regex = { +# "proton": "\".*proton\" run", # proton +# } -class Wrapper: +class WrapperDialog(QDialog): pass class WrapperWidget(QFrame): - update_wrapper = pyqtSignal(str, str) - delete_wrapper = pyqtSignal(str) + # object: current, object: new + update_wrapper = pyqtSignal(object, object) + # object: current + delete_wrapper = pyqtSignal(object) - def __init__(self, text: str, show_text=None, parent=None): + def __init__(self, wrapper: Wrapper, parent=None): super(WrapperWidget, self).__init__(parent=parent) - if not show_text: - show_text = text.split()[0] - self.setFrameShape(QFrame.StyledPanel) self.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + self.setToolTip(wrapper.command) - self.text = text - self.setToolTip(text) - - unmanaged = show_text in extra_wrapper_regex.keys() - - text_lbl = QLabel(show_text, parent=self) + text_lbl = QLabel(wrapper.name, parent=self) text_lbl.setFont(QFont("monospace")) - text_lbl.setDisabled(unmanaged) + text_lbl.setEnabled(wrapper.is_editable) image_lbl = QLabel(parent=self) image_lbl.setPixmap(icon("mdi.drag-vertical").pixmap(QSize(20, 20))) @@ -72,8 +67,8 @@ class WrapperWidget(QFrame): manage_button.setIcon(icon("mdi.menu")) manage_button.setMenu(manage_menu) manage_button.setPopupMode(QToolButton.InstantPopup) - manage_button.setDisabled(unmanaged) - if unmanaged: + manage_button.setEnabled(wrapper.is_editable) + if not wrapper.is_editable: manage_button.setToolTip(self.tr("Manage through settings")) else: manage_button.setToolTip(self.tr("Manage")) @@ -85,28 +80,39 @@ class WrapperWidget(QFrame): layout.addWidget(manage_button) self.setLayout(layout) + self.wrapper = wrapper + # lk: set object names for the stylesheet self.setObjectName(type(self).__name__) manage_button.setObjectName(f"{self.objectName()}Button") - @pyqtSlot() - def __delete(self): - self.delete_wrapper.emit(self.text) + def data(self) -> Wrapper: + return self.wrapper + @pyqtSlot() + def __delete(self) -> None: + self.delete_wrapper.emit(self.wrapper) + self.deleteLater() + + @pyqtSlot() def __edit(self) -> None: dialog = QInputDialog(self) dialog.setWindowTitle(f"{self.tr('Edit wrapper')} - {QCoreApplication.instance().applicationName()}") dialog.setLabelText(self.tr("Edit wrapper command")) - dialog.setTextValue(self.text) + dialog.setTextValue(self.wrapper.command) accepted = dialog.exec() - wrapper = dialog.textValue() + command = dialog.textValue() dialog.deleteLater() - if accepted and wrapper: - self.update_wrapper.emit(self.text, wrapper) + if accepted and command: + new_wrapper = Wrapper(command=shlex.split(command)) + self.update_wrapper.emit(self.wrapper, new_wrapper) + self.deleteLater() def mouseMoveEvent(self, a0: QMouseEvent) -> None: if a0.buttons() == Qt.LeftButton: a0.accept() + if self.wrapper.is_compat_tool: + return drag = QDrag(self) mime = QMimeData() drag.setMimeData(mime) @@ -114,30 +120,22 @@ class WrapperWidget(QFrame): class WrapperSettings(QWidget): - def __init__(self): - super(WrapperSettings, self).__init__() + def __init__(self, parent=None): + super(WrapperSettings, self).__init__(parent=parent) self.ui = Ui_WrapperSettings() self.ui.setupUi(self) - self.wrappers: Dict[str, WrapperWidget] = {} - self.app_name: str = "default" - self.wrapper_scroll = QScrollArea(self.ui.widget_stack) self.wrapper_scroll.setWidgetResizable(True) self.wrapper_scroll.setSizeAdjustPolicy(QScrollArea.AdjustToContents) self.wrapper_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.wrapper_scroll.setProperty("no_kinetic_scroll", True) - self.scroll_content = WrapperContainer( - save_cb=self.save, parent=self.wrapper_scroll - ) - self.wrapper_scroll.setWidget(self.scroll_content) + self.wrapper_container = WrapperContainer(parent=self.wrapper_scroll) + self.wrapper_container.orderChanged.connect(self.__on_order_changed) + self.wrapper_scroll.setWidget(self.wrapper_container) self.ui.widget_stack.insertWidget(0, self.wrapper_scroll) - self.core = RareCore.instance().core() - - self.ui.add_button.clicked.connect(self.add_button_pressed) - self.settings = QSettings() - + self.ui.add_button.clicked.connect(self.__on_add_button_pressed) self.wrapper_scroll.horizontalScrollBar().rangeChanged.connect(self.adjust_scrollarea) # lk: set object names for the stylesheet @@ -149,18 +147,24 @@ class WrapperSettings(QWidget): self.wrapper_scroll.verticalScrollBar().setObjectName( f"{self.wrapper_scroll.objectName()}Bar") + self.ui.wrapper_settings_layout.setAlignment(Qt.AlignTop) + + self.app_name: str = "default" + self.core = RareCore.instance().core() + self.wrappers = RareCore.instance().wrappers() + @pyqtSlot(int, int) - def adjust_scrollarea(self, min: int, max: int): - wrapper_widget = self.scroll_content.findChild(WrapperWidget) + def adjust_scrollarea(self, minh: int, maxh: int): + wrapper_widget = self.wrapper_container.findChild(WrapperWidget) if not wrapper_widget: - return + return # lk: when the scrollbar is not visible, min and max are 0 - if max > min: + if maxh > minh: self.wrapper_scroll.setMaximumHeight( wrapper_widget.sizeHint().height() + self.wrapper_scroll.rect().height() // 2 - self.wrapper_scroll.contentsRect().height() // 2 - + self.scroll_content.layout().spacing() + + self.wrapper_container.layout().spacing() + self.wrapper_scroll.horizontalScrollBar().sizeHint().height() ) else: @@ -170,187 +174,183 @@ class WrapperSettings(QWidget): - self.wrapper_scroll.contentsRect().height() ) - def get_wrapper_string(self): - return " ".join(self.get_wrapper_list()) + @pyqtSlot(QWidget, int) + def __on_order_changed(self, widget: WrapperWidget, new_index: int): + wrapper = widget.data() + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + wrappers.remove(wrapper) + wrappers.insert(new_index, wrapper) + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) - def get_wrapper_list(self): - wrappers = list(self.wrappers.values()) - wrappers.sort(key=lambda x: self.scroll_content.layout().indexOf(x)) - return [w.text for w in wrappers] - - def add_button_pressed(self): + @pyqtSlot() + def __on_add_button_pressed(self): dialog = QInputDialog(self) dialog.setWindowTitle(f"{self.tr('Add wrapper')} - {QCoreApplication.instance().applicationName()}") dialog.setLabelText(self.tr("Enter wrapper command")) accepted = dialog.exec() - wrapper = dialog.textValue() + command = dialog.textValue() dialog.deleteLater() if accepted: - self.add_wrapper(wrapper) - - def add_wrapper(self, text: str, position: int = -1, from_load: bool = False): - if text == "mangohud" and self.wrappers.get("mangohud"): - return - show_text = "" - for key, extra_wrapper in extra_wrapper_regex.items(): - if re.match(extra_wrapper, text): - show_text = key - if not show_text: - show_text = text.split()[0] - - # validate - if not text.strip(): # is empty - return - if not from_load: - if self.wrappers.get(text): - QMessageBox.warning( - self, self.tr("Warning"), self.tr("Wrapper {0} is already in the list").format(text) - ) - return - - if show_text != "proton" and not shutil.which(text.split()[0]): - if ( - QMessageBox.question( - self, - self.tr("Warning"), - self.tr("Wrapper {0} is not in $PATH. Add it anyway?").format(show_text), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - == QMessageBox.No - ): - return - - if text == "proton": - QMessageBox.warning( - self, - self.tr("Warning"), - self.tr("Do not insert proton manually. Add it through Proton settings"), - ) - return + wrapper = Wrapper(shlex.split(command)) + self.add_user_wrapper(wrapper) + def __add_wrapper(self, wrapper: Wrapper, position: int = -1): self.ui.widget_stack.setCurrentIndex(0) - - if widget := self.wrappers.get(show_text, None): - widget.deleteLater() - - widget = WrapperWidget(text, show_text, self.scroll_content) + widget = WrapperWidget(wrapper, self.wrapper_container) if position < 0: - self.scroll_content.layout().addWidget(widget) + self.wrapper_container.addWidget(widget) else: - self.scroll_content.layout().insertWidget(position, widget) + self.wrapper_container.insertWidget(position, widget) self.adjust_scrollarea( self.wrapper_scroll.horizontalScrollBar().minimum(), self.wrapper_scroll.horizontalScrollBar().maximum(), ) - widget.update_wrapper.connect(self.update_wrapper) - widget.delete_wrapper.connect(self.delete_wrapper) + widget.update_wrapper.connect(self.__update_wrapper) + widget.delete_wrapper.connect(self.__delete_wrapper) - self.wrappers[show_text] = widget + def add_wrapper(self, wrapper: Wrapper, position: int = -1): + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + if position < 0 or wrapper.is_compat_tool: + wrappers.append(wrapper) + else: + wrappers.insert(position, wrapper) + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) + self.__add_wrapper(wrapper, position) - if not from_load: - self.save() + def add_user_wrapper(self, wrapper: Wrapper, position: int = -1): + if not wrapper: + return - @pyqtSlot(str) - def delete_wrapper(self, text: str): - text = text.split()[0] - widget = self.wrappers.get(text, None) - if widget: - self.wrappers.pop(text) - widget.deleteLater() + compat_cmds = [tool.command() for tool in proton.find_tools()] + if wrapper.command in compat_cmds: + QMessageBox.warning( + self, + self.tr("Warning"), + self.tr("Do not insert compatibility tools manually. Add them through Proton settings"), + ) + return - if not self.wrappers: + # if text == "mangohud" and self.wrappers.get("mangohud"): + # return + # show_text = "" + # for key, extra_wrapper in extra_wrapper_regex.items(): + # if re.match(extra_wrapper, text): + # show_text = key + # if not show_text: + # show_text = text.split()[0] + + if wrapper.checksum in self.wrappers.get_game_md5sum_list(self.app_name): + QMessageBox.warning( + self, self.tr("Warning"), self.tr("Wrapper {0} is already in the list").format(wrapper.command) + ) + return + + if not shutil.which(wrapper.executable): + ans = QMessageBox.question( + self, + self.tr("Warning"), + self.tr("Wrapper {0} is not in $PATH. Add it anyway?").format(wrapper.executable), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if ans == QMessageBox.No: + return + + self.add_wrapper(wrapper, position) + + @pyqtSlot(object) + def __delete_wrapper(self, wrapper: Wrapper): + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + wrappers.remove(wrapper) + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) + if not wrappers: self.wrapper_scroll.setMaximumHeight(self.ui.label_page.sizeHint().height()) self.ui.widget_stack.setCurrentIndex(1) - self.save() + @pyqtSlot(object, object) + def __update_wrapper(self, old: Wrapper, new: Wrapper): + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + index = wrappers.index(old) + wrappers.remove(old) + wrappers.insert(index, new) + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) + self.__add_wrapper(new, index) - @pyqtSlot(str, str) - def update_wrapper(self, old: str, new: str): - key = old.split()[0] - idx = self.scroll_content.layout().indexOf(self.wrappers[key]) - self.delete_wrapper(key) - self.add_wrapper(new, position=idx) - - def save(self): - # save wrappers twice, to support wrappers with spaces - if len(self.wrappers) == 0: - config_helper.remove_option(self.app_name, "wrapper") - self.settings.remove(f"{self.app_name}/wrapper") - else: - config_helper.add_option(self.app_name, "wrapper", self.get_wrapper_string()) - self.settings.setValue(f"{self.app_name}/wrapper", self.get_wrapper_list()) - - def load_settings(self, app_name: str): - self.app_name = app_name - for i in self.wrappers.values(): - i.deleteLater() - self.wrappers.clear() - - wrappers = self.settings.value(f"{self.app_name}/wrapper", [], str) - - if not wrappers and (cfg := self.core.lgd.config.get(self.app_name, "wrapper", fallback="")): - logger.info("Loading wrappers from legendary config") - # no qt wrapper, but legendary wrapper, to have backward compatibility - pattern = re.compile(r'''((?:[^ "']|"[^"]*"|'[^']*')+)''') - wrappers = pattern.split(cfg)[1::2] - - for wrapper in wrappers: - self.add_wrapper(wrapper, from_load=True) - - if not self.wrappers: + @pyqtSlot() + def update_state(self): + for w in self.wrapper_container.findChildren(WrapperWidget, options=Qt.FindDirectChildrenOnly): + w.deleteLater() + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + if not wrappers: self.wrapper_scroll.setMaximumHeight(self.ui.label_page.sizeHint().height()) self.ui.widget_stack.setCurrentIndex(1) else: self.ui.widget_stack.setCurrentIndex(0) + for wrapper in wrappers: + self.__add_wrapper(wrapper) - self.save() + def load_settings(self, app_name: str): + self.app_name = app_name + self.update_state() class WrapperContainer(QWidget): + # QWidget: moving widget, int: new index + orderChanged: pyqtSignal = pyqtSignal(QWidget, int) - def __init__(self, save_cb, parent=None): + def __init__(self, parent=None): super(WrapperContainer, self).__init__(parent=parent) self.setAcceptDrops(True) - self.save = save_cb - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) - self.setLayout(layout) + self.__layout = QHBoxLayout(self) + self.__layout.setContentsMargins(0, 0, 0, 0) + self.__layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) - self.drag_widget: Optional[QWidget] = None + self.__drag_widget: Optional[QWidget] = None # lk: set object names for the stylesheet self.setObjectName(type(self).__name__) + # def count(self) -> int: + # return self.__layout.count() + # + # def itemData(self, index: int) -> Any: + # widget: WrapperWidget = self.__layout.itemAt(index).widget() + # return widget.data() + + def addWidget(self, widget: WrapperWidget): + self.__layout.addWidget(widget) + + def insertWidget(self, index: int, widget: WrapperWidget): + self.__layout.insertWidget(index, widget) + def dragEnterEvent(self, e: QDragEnterEvent): widget = e.source() - self.drag_widget = widget + self.__drag_widget = widget e.accept() - def _get_drop_index(self, x): - drag_idx = self.layout().indexOf(self.drag_widget) + def __get_drop_index(self, x) -> int: + drag_idx = self.__layout.indexOf(self.__drag_widget) if drag_idx > 0: - prev_widget = self.layout().itemAt(drag_idx - 1).widget() - if x < self.drag_widget.x() - prev_widget.width() // 2: + prev_widget = self.__layout.itemAt(drag_idx - 1).widget() + if x < self.__drag_widget.x() - prev_widget.width() // 2: return drag_idx - 1 - if drag_idx < self.layout().count() - 1: - next_widget = self.layout().itemAt(drag_idx + 1).widget() - if x > self.drag_widget.x() + self.drag_widget.width() + next_widget.width() // 2: + if drag_idx < self.__layout.count() - 1: + next_widget = self.__layout.itemAt(drag_idx + 1).widget() + if x > self.__drag_widget.x() + self.__drag_widget.width() + next_widget.width() // 2: return drag_idx + 1 return drag_idx def dragMoveEvent(self, e: QDragMoveEvent) -> None: - i = self._get_drop_index(e.pos().x()) - self.layout().insertWidget(i, self.drag_widget) + new_x = self.__get_drop_index(e.pos().x()) + self.__layout.insertWidget(new_x, self.__drag_widget) def dropEvent(self, e: QDropEvent): pos = e.pos() widget = e.source() - index = self._get_drop_index(pos.x()) - self.layout().insertWidget(index, widget) - self.drag_widget = None + new_x = self.__get_drop_index(pos.x()) + self.__layout.insertWidget(new_x, widget) + self.__drag_widget = None + self.orderChanged.emit(widget, new_x) e.accept() - self.save() diff --git a/rare/components/tray_icon.py b/rare/components/tray_icon.py index 8b40cca1..e9d56185 100644 --- a/rare/components/tray_icon.py +++ b/rare/components/tray_icon.py @@ -5,6 +5,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QSettings from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction, QApplication +from rare.models.options import options from rare.shared import RareCore logger = getLogger("TrayIcon") @@ -61,7 +62,7 @@ class TrayIcon(QSystemTrayIcon): @pyqtSlot(str, str) def notify(self, title: str, body: str): - if self.settings.value("notification", True, bool): + if self.settings.value(*options.notification): self.showMessage(f"{QApplication.applicationName()} - {title}", body, QSystemTrayIcon.Information, 4000) @pyqtSlot() diff --git a/rare/launcher/__init__.py b/rare/launcher/__init__.py index a0439924..978b7854 100644 --- a/rare/launcher/__init__.py +++ b/rare/launcher/__init__.py @@ -17,6 +17,7 @@ from legendary.models.game import SaveGameStatus from rare.lgndr.core import LegendaryCore from rare.models.base_game import RareGameSlim from rare.models.launcher import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel +from rare.models.options import options from rare.widgets.rare_app import RareApp, RareAppException from .cloud_sync_dialog import CloudSyncDialog, CloudSyncDialogResult from .console_dialog import ConsoleDialog @@ -145,7 +146,7 @@ class RareLauncher(RareApp): lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) - if QSettings().value("show_console", False, bool): + if QSettings(self).value(*options.log_games): self.console = ConsoleDialog() self.console.show() diff --git a/rare/main.py b/rare/main.py index ac173cfc..4d324d20 100755 --- a/rare/main.py +++ b/rare/main.py @@ -18,6 +18,8 @@ def main() -> int: sys.stderr = open(os.devnull, 'w') os.environ["QT_QPA_PLATFORMTHEME"] = "" + os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" + os.environ["QT_SCALE_FACTOR_ROUNDING_POLICY"] = "Floor" if "LEGENDARY_CONFIG_PATH" in os.environ: os.environ["LEGENDARY_CONFIG_PATH"] = os.path.expanduser(os.environ["LEGENDARY_CONFIG_PATH"]) diff --git a/rare/models/base_game.py b/rare/models/base_game.py index 0033530a..8b65295f 100644 --- a/rare/models/base_game.py +++ b/rare/models/base_game.py @@ -10,6 +10,7 @@ from legendary.lfs import eos from legendary.models.game import SaveGameFile, SaveGameStatus, Game, InstalledGame from legendary.utils.selective_dl import get_sdl_appname +from rare.models.options import options from rare.lgndr.core import LegendaryCore from rare.models.install import UninstallOptionsModel, InstallOptionsModel @@ -222,11 +223,13 @@ class RareGameSlim(RareGameBase): @property def auto_sync_saves(self): - return self.supports_cloud_saves and QSettings().value( - f"{self.app_name}/auto_sync_cloud", - QSettings().value("auto_sync_cloud", False, bool), - bool + auto_sync_cloud = QSettings(self).value( + f"{self.app_name}/{options.auto_sync_cloud.key}", + options.auto_sync_cloud.default, + options.auto_sync_cloud.dtype ) + auto_sync_cloud = auto_sync_cloud or QSettings(self).value(*options.auto_sync_cloud) + return self.supports_cloud_saves and auto_sync_cloud @property def save_path(self) -> Optional[str]: diff --git a/rare/models/game.py b/rare/models/game.py index 8cc509d4..94b31f08 100644 --- a/rare/models/game.py +++ b/rare/models/game.py @@ -2,7 +2,7 @@ import json import os import platform from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, UTC from logging import getLogger from threading import Lock from typing import List, Optional, Dict, Set @@ -19,7 +19,7 @@ from rare.shared.game_process import GameProcess from rare.shared.image_manager import ImageManager from rare.utils.paths import data_dir, get_rare_executable from rare.utils.steam_grades import get_rating -from rare.utils.config_helper import add_envvar, remove_envvar +from rare.utils.config_helper import add_envvar logger = getLogger("RareGame") @@ -27,11 +27,10 @@ logger = getLogger("RareGame") class RareGame(RareGameSlim): @dataclass class Metadata: - auto_update: bool = False queued: bool = False queue_pos: Optional[int] = None last_played: datetime = datetime.min - grant_date: Optional[datetime] = None + grant_date: datetime = datetime.min steam_appid: Optional[int] = None steam_grade: Optional[str] = None steam_date: datetime = datetime.min @@ -40,24 +39,23 @@ class RareGame(RareGameSlim): @classmethod def from_dict(cls, data: Dict): return cls( - auto_update=data.get("auto_update", False), queued=data.get("queued", False), queue_pos=data.get("queue_pos", None), - last_played=datetime.fromisoformat(data["last_played"]) if data.get("last_played", None) else datetime.min, - grant_date=datetime.fromisoformat(data["grant_date"]) if data.get("grant_date", None) else None, + last_played=datetime.fromisoformat(x) if (x := data.get("last_played", None)) else datetime.min, + grant_date=datetime.fromisoformat(x) if (x := data.get("grant_date", None)) else datetime.min, steam_appid=data.get("steam_appid", None), steam_grade=data.get("steam_grade", None), - steam_date=datetime.fromisoformat(data["steam_date"]) if data.get("steam_date", None) else datetime.min, + steam_date=datetime.fromisoformat(x) if (x := data.get("steam_date", None)) else datetime.min, tags=data.get("tags", []), ) - def as_dict(self): + @property + def __dict__(self): return dict( - auto_update=self.auto_update, queued=self.queued, queue_pos=self.queue_pos, last_played=self.last_played.isoformat() if self.last_played else datetime.min, - grant_date=self.grant_date.isoformat() if self.grant_date else None, + grant_date=self.grant_date.isoformat() if self.grant_date else datetime.min, steam_appid=self.steam_appid, steam_grade=self.steam_grade, steam_date=self.steam_date.isoformat() if self.steam_date else datetime.min, @@ -81,8 +79,7 @@ class RareGame(RareGameSlim): self.pixmap: QPixmap = QPixmap() self.metadata: RareGame.Metadata = RareGame.Metadata() self.__load_metadata() - if self.metadata.grant_date is None: - self.grant_date() + self.grant_date() self.owned_dlcs: Set[RareGame] = set() @@ -143,13 +140,14 @@ class RareGame(RareGameSlim): def __load_metadata_json() -> Dict: if RareGame.__metadata_json is None: metadata = {} + file = os.path.join(data_dir(), "game_meta.json") try: - with open(os.path.join(data_dir(), "game_meta.json"), "r") as metadata_fh: - metadata = json.load(metadata_fh) + with open(file, "r") as f: + metadata = json.load(f) except FileNotFoundError: - logger.info("Game metadata json file does not exist.") + logger.info("%s does not exist", file) except json.JSONDecodeError: - logger.warning("Game metadata json file is corrupt.") + logger.warning("%s is corrupt", file) finally: RareGame.__metadata_json = metadata return RareGame.__metadata_json @@ -166,9 +164,9 @@ class RareGame(RareGameSlim): with RareGame.__metadata_lock: metadata: Dict = self.__load_metadata_json() # pylint: disable=unsupported-assignment-operation - metadata[self.app_name] = self.metadata.as_dict() - with open(os.path.join(data_dir(), "game_meta.json"), "w") as metadata_json: - json.dump(metadata, metadata_json, indent=2) + metadata[self.app_name] = vars(self.metadata) + with open(os.path.join(data_dir(), "game_meta.json"), "w+") as file: + json.dump(metadata, file, indent=2) def update_game(self): self.game = self.core.get_game( @@ -432,29 +430,27 @@ class RareGame(RareGameSlim): def steam_grade(self) -> str: if platform.system() == "Windows" or self.is_unreal: return "na" - elapsed_time = abs(datetime.utcnow() - self.metadata.steam_date) - if ( - self.metadata.steam_grade is not None - and self.metadata.steam_appid is not None - and elapsed_time.days < 3 - ): - return self.metadata.steam_grade + if self.metadata.steam_grade != "pending": + elapsed_time = abs(datetime.utcnow() - self.metadata.steam_date) - def _set_steam_grade(): - appid, rating = get_rating(self.core, self.app_name) - self.set_steam_grade(appid, rating) - - worker = QRunnable.create(_set_steam_grade) - QThreadPool.globalInstance().start(worker) - return "pending" + if elapsed_time.days > 3 and (self.metadata.steam_grade is None or self.metadata.steam_appid is None): + def _set_steam_grade(): + appid, rating = get_rating(self.core, self.app_name) + self.set_steam_grade(appid, rating) + worker = QRunnable.create(_set_steam_grade) + QThreadPool.globalInstance().start(worker) + self.metadata.steam_grade = "pending" + return self.metadata.steam_grade @property def steam_appid(self) -> Optional[int]: return self.metadata.steam_appid def set_steam_grade(self, appid: int, grade: str) -> None: - if appid or self.steam_appid is None: + if appid and self.steam_appid is None: add_envvar(self.app_name, "SteamAppId", str(appid)) + add_envvar(self.app_name, "SteamGameId", str(appid)) + add_envvar(self.app_name, "STEAM_COMPAT_APP_ID", str(appid)) self.metadata.steam_appid = appid self.metadata.steam_grade = grade self.metadata.steam_date = datetime.utcnow() @@ -463,17 +459,17 @@ class RareGame(RareGameSlim): def grant_date(self, force=False) -> datetime: if (entitlements := self.core.lgd.entitlements) is None: - return self.metadata.grant_date - if self.metadata.grant_date is None or force: + return self.metadata.grant_date.replace(tzinfo=UTC) + if self.metadata.grant_date == datetime.min.replace(tzinfo=UTC) or force: logger.debug("Grant date for %s not found in metadata, resolving", self.app_name) matching = filter(lambda ent: ent["namespace"] == self.game.namespace, entitlements) entitlement = next(matching, None) grant_date = datetime.fromisoformat( entitlement["grantDate"].replace("Z", "+00:00") - ) if entitlement else None + ) if entitlement else datetime.min.replace(tzinfo=UTC) self.metadata.grant_date = grant_date self.__save_metadata() - return self.metadata.grant_date + return self.metadata.grant_date.replace(tzinfo=UTC) def set_origin_attributes(self, path: str, size: int = 0) -> None: self.__origin_install_path = path diff --git a/rare/models/library.py b/rare/models/library.py new file mode 100644 index 00000000..9318a2fe --- /dev/null +++ b/rare/models/library.py @@ -0,0 +1,19 @@ +from enum import IntEnum + + +class LibraryFilter(IntEnum): + ALL = 1 + INSTALLED = 2 + OFFLINE = 3 + HIDDEN = 4 + WIN32 = 5 + MAC = 6 + INSTALLABLE = 7 + INCLUDE_UE = 8 + + +class LibraryOrder(IntEnum): + TITLE = 1 + RECENT = 2 + NEWEST = 3 + OLDEST = 4 diff --git a/rare/models/options.py b/rare/models/options.py new file mode 100644 index 00000000..4a062d09 --- /dev/null +++ b/rare/models/options.py @@ -0,0 +1,53 @@ +import platform as pf +from argparse import Namespace +from typing import Any, Type +from .library import LibraryFilter, LibraryOrder + + +class Value(Namespace): + key: str + default: Any + dtype: Type + + def __len__(self): + return len(self.__dict__) + + def __iter__(self): + for v in self.__dict__.values(): + yield v + + +# They key names are set to the existing option name +class Defaults(Namespace): + win32_meta = Value(key="win32_meta", default=False, dtype=bool) + macos_meta = Value(key="macos_meta", default=pf.system() == "Darwin", dtype=bool) + unreal_meta = Value(key="unreal_meta", default=False, dtype=bool) + exclude_non_asset = Value(key="exclude_non_asset", default=False, dtype=bool) + exclude_entitlements = Value(key="exclude_entitlements", default=False, dtype=bool) + + sys_tray = Value(key="sys_tray", default=True, dtype=bool) + auto_update = Value(key="auto_update", default=False, dtype=bool) + auto_sync_cloud = Value(key="auto_sync_cloud", default=False, dtype=bool) + confirm_start = Value(key="confirm_start", default=False, dtype=bool) + save_size = Value(key="save_size", default=False, dtype=bool) + window_size = Value(key="window_size", default=(1280, 720), dtype=tuple) + notification = Value(key="notification", default=True, dtype=bool) + log_games = Value(key="show_console", default=False, dtype=bool) + + library_filter = Value( + key="library_filter", + default=int(LibraryFilter.MAC if pf.system() == "Darwin" else LibraryFilter.ALL), dtype=int + ) + library_order = Value( + key="library_order", default=int(LibraryOrder.TITLE), dtype=int + ) + + rpc_enable = Value(key="rpc_enable", default=0, dtype=int) + rpc_name = Value(key="rpc_game", default=True, dtype=bool) + rpc_time = Value(key="rpc_time", default=True, dtype=bool) + rpc_os = Value(key="rpc_os", default=True, dtype=bool) + + +options = Defaults() + +__all__ = ['options'] diff --git a/rare/models/pathspec.py b/rare/models/pathspec.py index 24c9d1a5..5249476e 100644 --- a/rare/models/pathspec.py +++ b/rare/models/pathspec.py @@ -2,46 +2,72 @@ import os from typing import Union, List from legendary.core import LegendaryCore +from legendary.models.game import InstalledGame + +from rare.utils.config_helper import get_prefixes class PathSpec: - __egl_path_vars = { - "{appdata}": os.path.expandvars("%LOCALAPPDATA%"), - "{userdir}": os.path.expandvars("%USERPROFILE%/Documents"), - # '{userprofile}': os.path.expandvars('%userprofile%'), # possibly wrong - "{usersavedgames}": os.path.expandvars("%USERPROFILE%/Saved Games"), - } - egl_appdata: str = r"%LOCALAPPDATA%\EpicGamesLauncher\Saved\Config\Windows" - egl_programdata: str = r"%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests" - wine_programdata: str = r"dosdevices/c:/ProgramData" - def __init__(self, core: LegendaryCore = None, app_name: str = "default"): - if core is not None: - self.__egl_path_vars.update({"{epicid}": core.lgd.userdata["account_id"]}) - self.app_name = app_name + @staticmethod + def egl_appdata() -> str: + return r"%LOCALAPPDATA%\EpicGamesLauncher\Saved\Config\Windows" - def cook(self, path: str) -> str: - cooked_path = [self.__egl_path_vars.get(p.lower(), p) for p in path.split("/")] - return os.path.join(*cooked_path) + @staticmethod + def egl_programdata() -> str: + return r"%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests" - @property - def wine_egl_programdata(self): - return self.egl_programdata.replace("\\", "/").replace("%PROGRAMDATA%", self.wine_programdata) + @staticmethod + def wine_programdata() -> str: + return r"ProgramData" - def wine_egl_prefixes(self, results: int = 0) -> Union[List[str], str]: - possible_prefixes = [ - os.path.expanduser("~/.wine"), - os.path.expanduser("~/Games/epic-games-store"), - ] + @staticmethod + def wine_egl_programdata() -> str: + return PathSpec.egl_programdata( + ).replace( + "\\", "/" + ).replace( + "%PROGRAMDATA%", PathSpec.wine_programdata() + ) + + @staticmethod + def prefix_egl_programdata(prefix: str) -> str: + return os.path.join(prefix, "dosdevices/c:", PathSpec.wine_egl_programdata()) + + @staticmethod + def wine_egl_prefixes(results: int = 0) -> Union[List[str], str]: + possible_prefixes = get_prefixes() prefixes = [] for prefix in possible_prefixes: - if os.path.exists(os.path.join(prefix, self.wine_egl_programdata)): + if os.path.exists(os.path.join(prefix, PathSpec.wine_egl_programdata())): prefixes.append(prefix) if not prefixes: - return str() + return "" if not results: return prefixes elif results == 1: return prefixes[0] else: return prefixes[:results] + + def __init__(self, core: LegendaryCore = None, igame: InstalledGame = None): + self.__egl_path_vars = { + "{appdata}": os.path.expandvars("%LOCALAPPDATA%"), + "{userdir}": os.path.expandvars("%USERPROFILE%/Documents"), + "{userprofile}": os.path.expandvars("%userprofile%"), # possibly wrong + "{usersavedgames}": os.path.expandvars("%USERPROFILE%/Saved Games"), + } + + if core is not None: + self.__egl_path_vars.update({ + "{epicid}": core.lgd.userdata["account_id"] + }) + + if igame is not None: + self.__egl_path_vars.update({ + "{installdir}": igame.install_path, + }) + + def resolve_egl_path_vars(self, path: str) -> str: + cooked_path = [self.__egl_path_vars.get(p.lower(), p) for p in path.split("/")] + return os.path.join(*cooked_path) diff --git a/rare/models/wrapper.py b/rare/models/wrapper.py new file mode 100644 index 00000000..09a0fc9e --- /dev/null +++ b/rare/models/wrapper.py @@ -0,0 +1,74 @@ +import os +import shlex +from hashlib import md5 +from enum import IntEnum +from typing import Dict, List, Union + + +class WrapperType(IntEnum): + NONE = 0 + COMPAT_TOOL = 1 + LEGENDARY_IMPORT = 8 + USER_DEFINED = 9 + + +class Wrapper: + def __init__(self, command: Union[str, List[str]], name: str = None, wtype: WrapperType = None): + self.__command: List[str] = shlex.split(command) if isinstance(command, str) else command + self.__name: str = name if name is not None else os.path.basename(self.__command[0]) + self.__wtype: WrapperType = wtype if wtype is not None else WrapperType.USER_DEFINED + + @property + def is_compat_tool(self) -> bool: + return self.__wtype == WrapperType.COMPAT_TOOL + + @property + def is_editable(self) -> bool: + return self.__wtype == WrapperType.USER_DEFINED or self.__wtype == WrapperType.LEGENDARY_IMPORT + + @property + def checksum(self) -> str: + return md5(self.command.encode("utf-8")).hexdigest() + + @property + def executable(self) -> str: + return shlex.quote(self.__command[0]) + + @property + def command(self) -> str: + return " ".join(shlex.quote(part) for part in self.__command) + + @property + def name(self) -> str: + return self.__name + + @property + def type(self) -> WrapperType: + return self.__wtype + + def __eq__(self, other) -> bool: + return self.command == other.command + + def __hash__(self): + return hash(self.__command) + + def __bool__(self) -> bool: + if not self.is_editable: + return True + return bool(self.command.strip()) + + @classmethod + def from_dict(cls, data: Dict): + return cls( + command=data.get("command"), + name=data.get("name"), + wtype=WrapperType(data.get("wtype", WrapperType.USER_DEFINED)) + ) + + @property + def __dict__(self): + return dict( + command=self.__command, + name=self.__name, + wtype=int(self.__wtype) + ) diff --git a/rare/shared/rare_core.py b/rare/shared/rare_core.py index 9afcaac2..d812addf 100644 --- a/rare/shared/rare_core.py +++ b/rare/shared/rare_core.py @@ -29,7 +29,7 @@ from .workers import ( ) from .workers.uninstall import uninstall_game from .workers.worker import QueueWorkerInfo, QueueWorkerState -from rare.utils import config_helper +from .wrappers import Wrappers logger = getLogger("RareCore") @@ -53,6 +53,8 @@ class RareCore(QObject): self.__signals: Optional[GlobalSignals] = None self.__core: Optional[LegendaryCore] = None self.__image_manager: Optional[ImageManager] = None + self.__settings: Optional[QSettings] = None + self.__wrappers: Optional[Wrappers] = None self.__start_time = time.perf_counter() @@ -61,8 +63,8 @@ class RareCore(QObject): self.core(init=True) config_helper.init_config_handler(self.__core) self.image_manager(init=True) - - self.settings = QSettings(self) + self.__settings = QSettings(self) + self.__wrappers = Wrappers() self.queue_workers: List[QueueWorker] = [] self.queue_threadpool = QThreadPool() @@ -205,6 +207,12 @@ class RareCore(QObject): self.__image_manager = ImageManager(self.signals(), self.core()) return self.__image_manager + def wrappers(self) -> Wrappers: + return self.__wrappers + + def settings(self) -> QSettings: + return self.__settings + def deleteLater(self) -> None: self.__image_manager.deleteLater() del self.__image_manager @@ -328,10 +336,13 @@ class RareCore(QObject): self.__core.lgd.entitlements = result self.__fetched_entitlements = True - logger.info(f"Acquired data for {FetchWorker.Result(result_type).name}") + logger.info("Acquired data from %s worker", FetchWorker.Result(result_type).name) if all([self.__fetched_games_dlcs, self.__fetched_entitlements]): - logger.debug(f"Fetch time {time.perf_counter() - self.__start_time} seconds") + logger.debug("Fetch time %s seconds", time.perf_counter() - self.__start_time) + self.__wrappers.import_wrappers( + self.__core, self.__settings, [rgame.app_name for rgame in self.games] + ) self.progress.emit(100, self.tr("Launching Rare")) self.completed.emit() QTimer.singleShot(100, self.__post_init) @@ -366,7 +377,7 @@ class RareCore(QObject): continue self.__library[app_name].load_saves(saves) except (HTTPError, ConnectionError) as e: - logger.error(f"Exception while fetching saves from EGS.") + logger.error("Exception while fetching saves from EGS.") logger.error(e) return logger.info(f"Saves: {len(saves_dict)}") diff --git a/rare/shared/workers/fetch.py b/rare/shared/workers/fetch.py index 1d4fe761..cac6a82e 100644 --- a/rare/shared/workers/fetch.py +++ b/rare/shared/workers/fetch.py @@ -8,6 +8,7 @@ from requests.exceptions import HTTPError, ConnectionError from rare.lgndr.core import LegendaryCore from rare.utils.metrics import timelogger +from rare.models.options import options from .worker import Worker logger = getLogger("FetchWorker") @@ -32,12 +33,12 @@ class FetchWorker(Worker): class EntitlementsWorker(FetchWorker): - def __init__(self, core: LegendaryCore, args: Namespace): - super(EntitlementsWorker, self).__init__(core, args) def run_real(self): + + want_entitlements = not self.settings.value(*options.exclude_entitlements) + entitlements = () - want_entitlements = not self.settings.value("exclude_entitlements", False, bool) if want_entitlements: # Get entitlements, Ubisoft integration also uses them self.signals.progress.emit(0, self.signals.tr("Updating entitlements")) @@ -51,20 +52,20 @@ class EntitlementsWorker(FetchWorker): class GamesDlcsWorker(FetchWorker): - def __init__(self, core: LegendaryCore, args: Namespace): - super(GamesDlcsWorker, self).__init__(core, args) - self.exclude_non_asset = QSettings().value("exclude_non_asset", False, bool) - def run_real(self): # Fetch regular EGL games with assets - want_unreal = self.settings.value("unreal_meta", False, bool) or self.args.debug - want_win32 = self.settings.value("win32_meta", False, bool) - want_macos = self.settings.value("macos_meta", False, bool) + # want_unreal = self.settings.value(*options.unreal_meta) or self.args.debug + # want_win32 = self.settings.value(*options.win32_meta) or self.args.debug + # want_macos = self.settings.value(*options.macos_meta) or self.args.debug + want_unreal = self.settings.value(*options.unreal_meta) + want_win32 = self.settings.value(*options.win32_meta) + want_macos = self.settings.value(*options.macos_meta) + want_non_asset = not self.settings.value(*options.exclude_non_asset) need_macos = platform.system() == "Darwin" - need_windows = not any([want_win32, want_macos, need_macos, self.args.debug]) and not self.args.offline + need_windows = not any([want_win32, want_macos, need_macos]) and not self.args.offline - if want_win32 or self.args.debug: + if want_win32: logger.info( "Requesting Win32 metadata due to %s, %s Unreal engine", "settings" if want_win32 else "debug", @@ -76,7 +77,7 @@ class GamesDlcsWorker(FetchWorker): update_assets=not self.args.offline, platform="Win32", skip_ue=not want_unreal ) - if need_macos or want_macos or self.args.debug: + if need_macos or want_macos: logger.info( "Requesting macOS metadata due to %s, %s Unreal engine", "platform" if need_macos else "settings" if want_macos else "debug", @@ -101,7 +102,6 @@ class GamesDlcsWorker(FetchWorker): logger.info(f"Games: %s. Games with DLCs: %s", len(games), len(dlc_dict)) # Fetch non-asset games - want_non_asset = not self.settings.value("exclude_non_asset", False, bool) if want_non_asset: self.signals.progress.emit(30, self.signals.tr("Updating non-asset game metadata")) try: diff --git a/rare/shared/workers/uninstall.py b/rare/shared/workers/uninstall.py index 98fe21b7..23566cee 100644 --- a/rare/shared/workers/uninstall.py +++ b/rare/shared/workers/uninstall.py @@ -11,7 +11,7 @@ from rare.lgndr.glue.arguments import LgndrUninstallGameArgs from rare.lgndr.glue.monkeys import LgndrIndirectStatus from rare.models.game import RareGame from rare.models.install import UninstallOptionsModel -from rare.utils import config_helper +from rare.utils import config_helper as config from rare.utils.paths import desktop_links_supported, desktop_link_types, desktop_link_path from .worker import Worker @@ -31,7 +31,7 @@ def uninstall_game( logger.info('Removing registry entries...') if platform.system() != "Window": - prefixes = config_helper.get_wine_prefixes() + prefixes = config.get_prefixes() if platform.system() == "Darwin": # TODO: add crossover support pass @@ -65,10 +65,10 @@ def uninstall_game( ) if not keep_config: logger.info("Removing sections in config file") - config_helper.remove_section(rgame.app_name) - config_helper.remove_section(f"{rgame.app_name}.env") + config.remove_section(rgame.app_name) + config.remove_section(f"{rgame.app_name}.env") - config_helper.save_config() + config.save_config() return status.success, status.message diff --git a/rare/shared/workers/wine_resolver.py b/rare/shared/workers/wine_resolver.py index fe63ea4e..ee8bce1a 100644 --- a/rare/shared/workers/wine_resolver.py +++ b/rare/shared/workers/wine_resolver.py @@ -3,50 +3,71 @@ import platform import time from configparser import ConfigParser from logging import getLogger -from typing import Union, Iterable +from typing import Union, Iterable, Mapping, List from PyQt5.QtCore import pyqtSignal, QObject, QRunnable -import rare.utils.wine as wine from rare.lgndr.core import LegendaryCore from rare.models.game import RareGame from rare.models.pathspec import PathSpec +from rare.utils import runners, config_helper as config from rare.utils.misc import path_size, format_size from .worker import Worker if platform.system() == "Windows": # noinspection PyUnresolvedReferences - import winreg # pylint: disable=E0401 + import winreg # pylint: disable=E0401 from legendary.lfs import windows_helpers logger = getLogger("WineResolver") -class WineResolver(Worker): +class WinePathResolver(Worker): class Signals(QObject): - result_ready = pyqtSignal(str) + result_ready = pyqtSignal(str, str) - def __init__(self, core: LegendaryCore, path: str, app_name: str): - super(WineResolver, self).__init__() - self.signals = WineResolver.Signals() - self.wine_env = wine.environ(core, app_name) - self.wine_exec = wine.wine(core, app_name) - self.path = PathSpec(core, app_name).cook(path) + def __init__(self, command: List[str], environ: Mapping, path: str): + super(WinePathResolver, self). __init__() + self.signals = WinePathResolver.Signals() + self.command = command + self.environ = environ + self.path = path + + @staticmethod + def _resolve_unix_path(cmd, env, path: str) -> str: + logger.info("Resolving path '%s'", path) + wine_path = runners.resolve_path(cmd, env, path) + logger.debug("Resolved Wine path '%s'", path) + unix_path = runners.convert_to_unix_path(cmd, env, wine_path) + logger.debug("Resolved Unix path '%s'", unix_path) + return unix_path def run_real(self): - if "WINEPREFIX" not in self.wine_env or not os.path.exists(self.wine_env["WINEPREFIX"]): - # pylint: disable=E1136 - self.signals.result_ready[str].emit("") - return - if not os.path.exists(self.wine_exec): - # pylint: disable=E1136 - self.signals.result_ready[str].emit("") - return - path = wine.resolve_path(self.wine_exec, self.wine_env, self.path) + path = self._resolve_unix_path(self.command, self.environ, self.path) + self.signals.result_ready.emit(path, "default") + return + + +class WineSavePathResolver(WinePathResolver): + + def __init__(self, core: LegendaryCore, rgame: RareGame): + cmd = core.get_app_launch_command(rgame.app_name) + env = core.get_app_environment(rgame.app_name) + env = runners.get_environment(env, silent=True) + path = PathSpec(core, rgame.igame).resolve_egl_path_vars(rgame.raw_save_path) + if not (cmd and env and path): + raise RuntimeError(f"Cannot setup {type(self).__name__}, missing infomation") + super(WineSavePathResolver, self).__init__(cmd, env, path) + self.rgame = rgame + + def run_real(self): + logger.info("Resolving save path for %s (%s)", self.rgame.app_title, self.rgame.app_name) + path = self._resolve_unix_path(self.command, self.environ, self.path) # Clean wine output - real_path = wine.convert_to_unix_path(self.wine_exec, self.wine_env, path) # pylint: disable=E1136 - self.signals.result_ready[str].emit(real_path) + if os.path.exists(path): + self.rgame.save_path = path + self.signals.result_ready.emit(path, self.rgame.app_name) return @@ -55,9 +76,7 @@ class OriginWineWorker(QRunnable): super(OriginWineWorker, self).__init__() self.__cache: dict[str, ConfigParser] = {} self.core = core - if isinstance(games, RareGame): - games = [games] - self.games = games + self.games = [games] if isinstance(games, RareGame) else games def run(self) -> None: t = time.time() @@ -79,15 +98,19 @@ class OriginWineWorker(QRunnable): if platform.system() == "Windows": install_dir = windows_helpers.query_registry_value(winreg.HKEY_LOCAL_MACHINE, reg_path, reg_key) else: - wine_env = wine.environ(self.core, rgame.app_name) - wine_exec = wine.wine(self.core, rgame.app_name) + command = self.core.get_app_launch_command(rgame.app_name) + environ = self.core.get_app_environment(rgame.app_name) + environ = runners.get_environment(environ, silent=True) + + prefix = config.get_prefix(rgame.app_name) + if not prefix: + return use_wine = False if not use_wine: # lk: this is the original way of getting the path by parsing "system.reg" - wine_prefix = wine.prefix(self.core, rgame.app_name) - reg = self.__cache.get(wine_prefix, None) or wine.read_registry("system.reg", wine_prefix) - self.__cache[wine_prefix] = reg + reg = self.__cache.get(prefix, None) or runners.read_registry("system.reg", prefix) + self.__cache[prefix] = reg reg_path = reg_path.replace("SOFTWARE", "Software").replace("WOW6432Node", "Wow6432Node") # lk: split and rejoin the registry path to avoid slash expansion @@ -96,11 +119,11 @@ class OriginWineWorker(QRunnable): install_dir = reg.get(reg_path, f'"{reg_key}"', fallback=None) else: # lk: this is the alternative way of getting the path by using wine itself - install_dir = wine.query_reg_key(wine_exec, wine_env, f"HKLM\\{reg_path}", reg_key) + install_dir = runners.query_reg_key(command, environ, f"HKLM\\{reg_path}", reg_key) if install_dir: logger.debug("Found Wine install directory %s", install_dir) - install_dir = wine.convert_to_unix_path(wine_exec, wine_env, install_dir) + install_dir = runners.convert_to_unix_path(command, environ, install_dir) if install_dir: logger.debug("Found Unix install directory %s", install_dir) else: diff --git a/rare/shared/wrappers.py b/rare/shared/wrappers.py new file mode 100644 index 00000000..3d146919 --- /dev/null +++ b/rare/shared/wrappers.py @@ -0,0 +1,169 @@ +import json +import os +from logging import getLogger +import shlex +from typing import List, Dict, Iterable +from rare.utils import config_helper as config + +from PyQt5.QtCore import QSettings + +from rare.lgndr.core import LegendaryCore +from rare.models.wrapper import Wrapper, WrapperType +from rare.utils.paths import config_dir + +logger = getLogger("Wrappers") + + +class Wrappers: + def __init__(self): + self.__file = os.path.join(config_dir(), "wrappers.json") + self.__wrappers_dict = {} + try: + with open(self.__file) as f: + self.__wrappers_dict = json.load(f) + except FileNotFoundError: + logger.info("%s does not exist", self.__file) + except json.JSONDecodeError: + logger.warning("%s is corrupt", self.__file) + + self.__wrappers: Dict[str, Wrapper] = {} + for wrap_id, wrapper in self.__wrappers_dict.get("wrappers", {}).items(): + self.__wrappers.update({wrap_id: Wrapper.from_dict(wrapper)}) + + self.__applists: Dict[str, List[str]] = {} + for app_name, wrapper_list in self.__wrappers_dict.get("applists", {}).items(): + self.__applists.update({app_name: wrapper_list}) + + def import_wrappers(self, core: LegendaryCore, settings: QSettings, app_names: List): + for app_name in app_names: + wrappers = self.get_game_wrapper_list(app_name) + if not wrappers and (commands := settings.value(f"{app_name}/wrapper", [], list)): + logger.info("Importing wrappers from Rare's config") + settings.remove(f"{app_name}/wrapper") + for command in commands: + wrapper = Wrapper(command=shlex.split(command)) + wrappers.append(wrapper) + self.set_game_wrapper_list(app_name, wrappers) + logger.debug("Imported previous wrappers in %s Rare: %s", app_name, wrapper.name) + + # NOTE: compatibility with Legendary + if not wrappers and (command := core.lgd.config.get(app_name, "wrapper", fallback="")): + logger.info("Importing wrappers from legendary's config") + # no qt wrapper, but legendary wrapper, to have backward compatibility + # pattern = re.compile(r'''((?:[^ "']|"[^"]*"|'[^']*')+)''') + # wrappers = pattern.split(command)[1::2] + wrapper = Wrapper( + command=shlex.split(command), + name="Imported from Legendary", + wtype=WrapperType.LEGENDARY_IMPORT + ) + wrappers = [wrapper] + self.set_game_wrapper_list(app_name, wrappers) + logger.debug("Imported existing wrappers in %s legendary: %s", app_name, wrapper.name) + + @property + def user_wrappers(self) -> Iterable[Wrapper]: + return filter(lambda w: w.is_editable, self.__wrappers.values()) + # for wrap in self.__wrappers.values(): + # if wrap.is_user_defined: + # yield wrap + + def get_game_wrapper_string(self, app_name: str) -> str: + commands = [wrapper.command for wrapper in self.get_game_wrapper_list(app_name)] + return " ".join(commands) + + def get_game_wrapper_list(self, app_name: str) -> List[Wrapper]: + _wrappers = [] + for wrap_id in self.__applists.get(app_name, []): + if wrap := self.__wrappers.get(wrap_id, None): + _wrappers.append(wrap) + return _wrappers + + def get_game_md5sum_list(self, app_name: str) -> List[str]: + return self.__applists.get(app_name, []) + + def set_game_wrapper_list(self, app_name: str, wrappers: List[Wrapper]) -> None: + _wrappers = sorted(wrappers, key=lambda w: w.is_compat_tool) + for w in _wrappers: + if (md5sum := w.checksum) in self.__wrappers.keys(): + if w != self.__wrappers[md5sum]: + logger.error( + "Non-unique md5sum for different wrappers %s, %s", + w.name, + self.__wrappers[md5sum].name, + ) + if w.is_compat_tool: + self.__wrappers.update({md5sum: w}) + else: + self.__wrappers.update({md5sum: w}) + self.__applists[app_name] = [w.checksum for w in _wrappers] + self.__save_config(app_name) + self.__save_wrappers() + + def __save_config(self, app_name: str): + command_string = self.get_game_wrapper_string(app_name) + if command_string: + config.add_option(app_name, "wrapper", command_string) + else: + config.remove_option(app_name, "wrapper") + config.save_config() + + def __save_wrappers(self): + existing = {wrap_id for wrap_id in self.__wrappers.keys()} + in_use = {wrap_id for wrappers in self.__applists.values() for wrap_id in wrappers} + + for redudant in existing.difference(in_use): + del self.__wrappers[redudant] + + self.__wrappers_dict["wrappers"] = self.__wrappers + self.__wrappers_dict["applists"] = self.__applists + + with open(os.path.join(self.__file), "w+") as f: + json.dump(self.__wrappers_dict, f, default=lambda o: vars(o), indent=2) + + +if __name__ == "__main__": + from pprint import pprint + from argparse import Namespace + + from rare.utils.runners import proton + + global config_dir + config_dir = os.getcwd + global config + config = Namespace() + config.add_option = lambda x, y, z: print(x, y, z) + config.remove_option = lambda x, y: print(x, y) + config.save_config = lambda: print() + + wr = Wrappers() + + w1 = Wrapper(command=["/usr/bin/w1"], wtype=WrapperType.NONE) + w2 = Wrapper(command=["/usr/bin/w2"], wtype=WrapperType.COMPAT_TOOL) + w3 = Wrapper(command=["/usr/bin/w3"], wtype=WrapperType.USER_DEFINED) + w4 = Wrapper(command=["/usr/bin/w4"], wtype=WrapperType.USER_DEFINED) + wr.set_game_wrapper_list("testgame", [w1, w2, w3, w4]) + + w5 = Wrapper(command=["/usr/bin/w5"], wtype=WrapperType.COMPAT_TOOL) + wr.set_game_wrapper_list("testgame2", [w2, w1, w5]) + + w6 = Wrapper(command=["/usr/bin/w 6", "-w", "-t"], wtype=WrapperType.USER_DEFINED) + wr.set_game_wrapper_list("testgame", [w1, w2, w3, w6]) + + w7 = Wrapper(command=["/usr/bin/w2"], wtype=WrapperType.COMPAT_TOOL) + wrs = wr.get_game_wrapper_list("testgame") + wrs.remove(w7) + wr.set_game_wrapper_list("testgame", wrs) + + game_wrappers = wr.get_game_wrapper_list("testgame") + pprint(game_wrappers) + game_wrappers = wr.get_game_wrapper_list("testgame2") + pprint(game_wrappers) + + for i, tool in enumerate(proton.find_tools()): + wt = Wrapper(command=tool.command(), name=tool.name, wtype=WrapperType.COMPAT_TOOL) + wr.set_game_wrapper_list(f"compat_game_{i}", [wt]) + print(wt.command) + + for wrp in wr.user_wrappers: + pprint(wrp) diff --git a/rare/ui/components/tabs/settings/game.py b/rare/ui/components/tabs/settings/game.py new file mode 100644 index 00000000..ee43ea14 --- /dev/null +++ b/rare/ui/components/tabs/settings/game.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'rare/ui/components/tabs/settings/game_settings.ui' +# +# Created by: PyQt5 UI code generator 5.15.10 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_GameSettings(object): + def setupUi(self, GameSettings): + GameSettings.setObjectName("GameSettings") + GameSettings.resize(393, 202) + self.main_layout = QtWidgets.QVBoxLayout(GameSettings) + self.main_layout.setObjectName("main_layout") + self.launch_settings_group = QtWidgets.QGroupBox(GameSettings) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.launch_settings_group.sizePolicy().hasHeightForWidth()) + self.launch_settings_group.setSizePolicy(sizePolicy) + self.launch_settings_group.setObjectName("launch_settings_group") + self.launch_layout = QtWidgets.QFormLayout(self.launch_settings_group) + self.launch_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.launch_layout.setObjectName("launch_layout") + self.skip_update_label = QtWidgets.QLabel(self.launch_settings_group) + self.skip_update_label.setObjectName("skip_update_label") + self.launch_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.skip_update_label) + self.skip_update_combo = QtWidgets.QComboBox(self.launch_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.skip_update_combo.sizePolicy().hasHeightForWidth()) + self.skip_update_combo.setSizePolicy(sizePolicy) + self.skip_update_combo.setObjectName("skip_update_combo") + self.skip_update_combo.addItem("") + self.skip_update_combo.addItem("") + self.skip_update_combo.addItem("") + self.launch_layout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.skip_update_combo) + self.offline_label = QtWidgets.QLabel(self.launch_settings_group) + self.offline_label.setObjectName("offline_label") + self.launch_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.offline_label) + self.offline_combo = QtWidgets.QComboBox(self.launch_settings_group) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.offline_combo.sizePolicy().hasHeightForWidth()) + self.offline_combo.setSizePolicy(sizePolicy) + self.offline_combo.setObjectName("offline_combo") + self.offline_combo.addItem("") + self.offline_combo.addItem("") + self.offline_combo.addItem("") + self.launch_layout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.offline_combo) + self.launch_params_label = QtWidgets.QLabel(self.launch_settings_group) + self.launch_params_label.setObjectName("launch_params_label") + self.launch_layout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.launch_params_label) + self.launch_params_edit = QtWidgets.QLineEdit(self.launch_settings_group) + self.launch_params_edit.setObjectName("launch_params_edit") + self.launch_layout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.launch_params_edit) + self.pre_launch_label = QtWidgets.QLabel(self.launch_settings_group) + self.pre_launch_label.setObjectName("pre_launch_label") + self.launch_layout.setWidget(5, QtWidgets.QFormLayout.LabelRole, self.pre_launch_label) + self.override_exe_label = QtWidgets.QLabel(self.launch_settings_group) + self.override_exe_label.setObjectName("override_exe_label") + self.launch_layout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.override_exe_label) + self.wrapper_label = QtWidgets.QLabel(self.launch_settings_group) + self.wrapper_label.setObjectName("wrapper_label") + self.launch_layout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.wrapper_label) + self.main_layout.addWidget(self.launch_settings_group) + + self.retranslateUi(GameSettings) + + def retranslateUi(self, GameSettings): + _translate = QtCore.QCoreApplication.translate + GameSettings.setWindowTitle(_translate("GameSettings", "GameSettings")) + self.launch_settings_group.setTitle(_translate("GameSettings", "Launch Settings")) + self.skip_update_label.setText(_translate("GameSettings", "Skip update check")) + self.skip_update_combo.setItemText(0, _translate("GameSettings", "Default")) + self.skip_update_combo.setItemText(1, _translate("GameSettings", "Yes")) + self.skip_update_combo.setItemText(2, _translate("GameSettings", "No")) + self.offline_label.setText(_translate("GameSettings", "Offline mode")) + self.offline_combo.setItemText(0, _translate("GameSettings", "Default")) + self.offline_combo.setItemText(1, _translate("GameSettings", "Yes")) + self.offline_combo.setItemText(2, _translate("GameSettings", "No")) + self.launch_params_label.setText(_translate("GameSettings", "Launch parameters")) + self.launch_params_edit.setPlaceholderText(_translate("GameSettings", "parameters")) + self.pre_launch_label.setText(_translate("GameSettings", "Pre-launch command")) + self.override_exe_label.setText(_translate("GameSettings", "Override executable")) + self.wrapper_label.setText(_translate("GameSettings", "Wrappers")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + GameSettings = QtWidgets.QWidget() + ui = Ui_GameSettings() + ui.setupUi(GameSettings) + GameSettings.show() + sys.exit(app.exec_()) diff --git a/rare/ui/components/tabs/settings/game_settings.ui b/rare/ui/components/tabs/settings/game.ui similarity index 75% rename from rare/ui/components/tabs/settings/game_settings.ui rename to rare/ui/components/tabs/settings/game.ui index 2927d7e9..88c26201 100644 --- a/rare/ui/components/tabs/settings/game_settings.ui +++ b/rare/ui/components/tabs/settings/game.ui @@ -6,14 +6,14 @@ 0 0 - 483 - 154 + 393 + 202 GameSettings - + @@ -25,7 +25,7 @@ Launch Settings - + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -37,7 +37,7 @@ - + 0 @@ -69,7 +69,7 @@ - + 0 @@ -101,12 +101,33 @@ - + parameters + + + + Pre-launch command + + + + + + + Override executable + + + + + + + Wrappers + + + diff --git a/rare/ui/components/tabs/settings/game_settings.py b/rare/ui/components/tabs/settings/game_settings.py deleted file mode 100644 index 0a1280be..00000000 --- a/rare/ui/components/tabs/settings/game_settings.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'rare/ui/components/tabs/settings/game_settings.ui' -# -# Created by: PyQt5 UI code generator 5.15.9 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_GameSettings(object): - def setupUi(self, GameSettings): - GameSettings.setObjectName("GameSettings") - GameSettings.resize(505, 261) - self.game_settings_layout = QtWidgets.QVBoxLayout(GameSettings) - self.game_settings_layout.setObjectName("game_settings_layout") - self.launch_settings_group = QtWidgets.QGroupBox(GameSettings) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.launch_settings_group.sizePolicy().hasHeightForWidth()) - self.launch_settings_group.setSizePolicy(sizePolicy) - self.launch_settings_group.setObjectName("launch_settings_group") - self.launch_settings_layout = QtWidgets.QFormLayout(self.launch_settings_group) - self.launch_settings_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.launch_settings_layout.setObjectName("launch_settings_layout") - self.skip_update_label = QtWidgets.QLabel(self.launch_settings_group) - self.skip_update_label.setObjectName("skip_update_label") - self.launch_settings_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.skip_update_label) - self.skip_update = QtWidgets.QComboBox(self.launch_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.skip_update.sizePolicy().hasHeightForWidth()) - self.skip_update.setSizePolicy(sizePolicy) - self.skip_update.setObjectName("skip_update") - self.skip_update.addItem("") - self.skip_update.addItem("") - self.skip_update.addItem("") - self.launch_settings_layout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.skip_update) - self.offline_label = QtWidgets.QLabel(self.launch_settings_group) - self.offline_label.setObjectName("offline_label") - self.launch_settings_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.offline_label) - self.offline = QtWidgets.QComboBox(self.launch_settings_group) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.offline.sizePolicy().hasHeightForWidth()) - self.offline.setSizePolicy(sizePolicy) - self.offline.setObjectName("offline") - self.offline.addItem("") - self.offline.addItem("") - self.offline.addItem("") - self.launch_settings_layout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.offline) - self.launch_params_label = QtWidgets.QLabel(self.launch_settings_group) - self.launch_params_label.setObjectName("launch_params_label") - self.launch_settings_layout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.launch_params_label) - self.launch_params = QtWidgets.QLineEdit(self.launch_settings_group) - self.launch_params.setObjectName("launch_params") - self.launch_settings_layout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.launch_params) - self.game_settings_layout.addWidget(self.launch_settings_group) - self.proton_layout = QtWidgets.QVBoxLayout() - self.proton_layout.setObjectName("proton_layout") - self.game_settings_layout.addLayout(self.proton_layout) - self.linux_settings_widget = QtWidgets.QWidget(GameSettings) - self.linux_settings_widget.setObjectName("linux_settings_widget") - self.linux_settings_layout = QtWidgets.QVBoxLayout(self.linux_settings_widget) - self.linux_settings_layout.setContentsMargins(0, 0, 0, 0) - self.linux_settings_layout.setObjectName("linux_settings_layout") - self.game_settings_layout.addWidget(self.linux_settings_widget) - - self.retranslateUi(GameSettings) - - def retranslateUi(self, GameSettings): - _translate = QtCore.QCoreApplication.translate - GameSettings.setWindowTitle(_translate("GameSettings", "GameSettings")) - self.launch_settings_group.setTitle(_translate("GameSettings", "Launch Settings")) - self.skip_update_label.setText(_translate("GameSettings", "Skip update check")) - self.skip_update.setItemText(0, _translate("GameSettings", "Default")) - self.skip_update.setItemText(1, _translate("GameSettings", "Yes")) - self.skip_update.setItemText(2, _translate("GameSettings", "No")) - self.offline_label.setText(_translate("GameSettings", "Offline mode")) - self.offline.setItemText(0, _translate("GameSettings", "Default")) - self.offline.setItemText(1, _translate("GameSettings", "Yes")) - self.offline.setItemText(2, _translate("GameSettings", "No")) - self.launch_params_label.setText(_translate("GameSettings", "Launch parameters")) - self.launch_params.setPlaceholderText(_translate("GameSettings", "parameters")) - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - GameSettings = QtWidgets.QWidget() - ui = Ui_GameSettings() - ui.setupUi(GameSettings) - GameSettings.show() - sys.exit(app.exec_()) diff --git a/rare/ui/components/tabs/settings/linux.py b/rare/ui/components/tabs/settings/linux.py deleted file mode 100644 index 63996350..00000000 --- a/rare/ui/components/tabs/settings/linux.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'rare/ui/components/tabs/settings/linux.ui' -# -# Created by: PyQt5 UI code generator 5.15.9 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_LinuxSettings(object): - def setupUi(self, LinuxSettings): - LinuxSettings.setObjectName("LinuxSettings") - LinuxSettings.resize(394, 84) - self.linux_layout = QtWidgets.QVBoxLayout(LinuxSettings) - self.linux_layout.setObjectName("linux_layout") - self.wine_groupbox = QtWidgets.QGroupBox(LinuxSettings) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.wine_groupbox.sizePolicy().hasHeightForWidth()) - self.wine_groupbox.setSizePolicy(sizePolicy) - self.wine_groupbox.setObjectName("wine_groupbox") - self.wine_layout = QtWidgets.QFormLayout(self.wine_groupbox) - self.wine_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.wine_layout.setObjectName("wine_layout") - self.prefix_label = QtWidgets.QLabel(self.wine_groupbox) - self.prefix_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.prefix_label.setObjectName("prefix_label") - self.wine_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.prefix_label) - self.prefix_layout = QtWidgets.QVBoxLayout() - self.prefix_layout.setObjectName("prefix_layout") - self.wine_layout.setLayout(0, QtWidgets.QFormLayout.FieldRole, self.prefix_layout) - self.exec_label = QtWidgets.QLabel(self.wine_groupbox) - self.exec_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.exec_label.setObjectName("exec_label") - self.wine_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.exec_label) - self.exec_layout = QtWidgets.QVBoxLayout() - self.exec_layout.setObjectName("exec_layout") - self.wine_layout.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.exec_layout) - self.linux_layout.addWidget(self.wine_groupbox) - - self.retranslateUi(LinuxSettings) - - def retranslateUi(self, LinuxSettings): - _translate = QtCore.QCoreApplication.translate - LinuxSettings.setWindowTitle(_translate("LinuxSettings", "LinuxSettings")) - self.wine_groupbox.setTitle(_translate("LinuxSettings", "Wine Settings")) - self.prefix_label.setText(_translate("LinuxSettings", "Prefix")) - self.exec_label.setText(_translate("LinuxSettings", "Executable")) - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - LinuxSettings = QtWidgets.QWidget() - ui = Ui_LinuxSettings() - ui.setupUi(LinuxSettings) - LinuxSettings.show() - sys.exit(app.exec_()) diff --git a/rare/ui/components/tabs/settings/linux.ui b/rare/ui/components/tabs/settings/linux.ui deleted file mode 100644 index 26e3721e..00000000 --- a/rare/ui/components/tabs/settings/linux.ui +++ /dev/null @@ -1,65 +0,0 @@ - - - LinuxSettings - - - - 0 - 0 - 394 - 84 - - - - LinuxSettings - - - - - - - 0 - 0 - - - - Wine Settings - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - Prefix - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - Executable - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - - - diff --git a/rare/ui/components/tabs/settings/proton.py b/rare/ui/components/tabs/settings/widgets/proton.py similarity index 69% rename from rare/ui/components/tabs/settings/proton.py rename to rare/ui/components/tabs/settings/widgets/proton.py index 4672bb5c..d40bfd2a 100644 --- a/rare/ui/components/tabs/settings/proton.py +++ b/rare/ui/components/tabs/settings/widgets/proton.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'rare/ui/components/tabs/settings/proton.ui' # -# Created by: PyQt5 UI code generator 5.15.6 +# Created by: PyQt5 UI code generator 5.15.10 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -14,14 +14,14 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_ProtonSettings(object): def setupUi(self, ProtonSettings): ProtonSettings.setObjectName("ProtonSettings") - ProtonSettings.resize(190, 86) + ProtonSettings.resize(180, 84) ProtonSettings.setWindowTitle("ProtonSettings") - self.proton_settings_layout = QtWidgets.QFormLayout(ProtonSettings) - self.proton_settings_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) - self.proton_settings_layout.setObjectName("proton_settings_layout") + self.main_layout = QtWidgets.QFormLayout(ProtonSettings) + self.main_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.main_layout.setObjectName("main_layout") self.proton_wrapper_label = QtWidgets.QLabel(ProtonSettings) self.proton_wrapper_label.setObjectName("proton_wrapper_label") - self.proton_settings_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.proton_wrapper_label) + self.main_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.proton_wrapper_label) self.proton_combo = QtWidgets.QComboBox(ProtonSettings) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -30,13 +30,10 @@ class Ui_ProtonSettings(object): self.proton_combo.setSizePolicy(sizePolicy) self.proton_combo.setObjectName("proton_combo") self.proton_combo.addItem("") - self.proton_settings_layout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.proton_combo) + self.main_layout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.proton_combo) self.proton_prefix_label = QtWidgets.QLabel(ProtonSettings) self.proton_prefix_label.setObjectName("proton_prefix_label") - self.proton_settings_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.proton_prefix_label) - self.prefix_layout = QtWidgets.QHBoxLayout() - self.prefix_layout.setObjectName("prefix_layout") - self.proton_settings_layout.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.prefix_layout) + self.main_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.proton_prefix_label) self.retranslateUi(ProtonSettings) diff --git a/rare/ui/components/tabs/settings/proton.ui b/rare/ui/components/tabs/settings/widgets/proton.ui similarity index 86% rename from rare/ui/components/tabs/settings/proton.ui rename to rare/ui/components/tabs/settings/widgets/proton.ui index 6b850d17..d25ac65e 100644 --- a/rare/ui/components/tabs/settings/proton.ui +++ b/rare/ui/components/tabs/settings/widgets/proton.ui @@ -6,8 +6,8 @@ 0 0 - 190 - 86 + 180 + 84 @@ -16,7 +16,7 @@ Proton Settings - + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -49,9 +49,6 @@ - - - diff --git a/rare/ui/components/tabs/settings/widgets/wine.py b/rare/ui/components/tabs/settings/widgets/wine.py new file mode 100644 index 00000000..6696e5f4 --- /dev/null +++ b/rare/ui/components/tabs/settings/widgets/wine.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'rare/ui/components/tabs/settings/wine.ui' +# +# Created by: PyQt5 UI code generator 5.15.10 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_WineSettings(object): + def setupUi(self, WineSettings): + WineSettings.setObjectName("WineSettings") + WineSettings.resize(104, 72) + WineSettings.setWindowTitle("WIneSettings") + self.main_layout = QtWidgets.QFormLayout(WineSettings) + self.main_layout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.main_layout.setObjectName("main_layout") + self.prefix_label = QtWidgets.QLabel(WineSettings) + self.prefix_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.prefix_label.setObjectName("prefix_label") + self.main_layout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.prefix_label) + self.exec_label = QtWidgets.QLabel(WineSettings) + self.exec_label.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.exec_label.setObjectName("exec_label") + self.main_layout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.exec_label) + + self.retranslateUi(WineSettings) + + def retranslateUi(self, WineSettings): + _translate = QtCore.QCoreApplication.translate + WineSettings.setTitle(_translate("WineSettings", "Wine Settings")) + self.prefix_label.setText(_translate("WineSettings", "Prefix")) + self.exec_label.setText(_translate("WineSettings", "Executable")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + WineSettings = QtWidgets.QGroupBox() + ui = Ui_WineSettings() + ui.setupUi(WineSettings) + WineSettings.show() + sys.exit(app.exec_()) diff --git a/rare/ui/components/tabs/settings/widgets/wine.ui b/rare/ui/components/tabs/settings/widgets/wine.ui new file mode 100644 index 00000000..ce3d5044 --- /dev/null +++ b/rare/ui/components/tabs/settings/widgets/wine.ui @@ -0,0 +1,47 @@ + + + WineSettings + + + + 0 + 0 + 104 + 72 + + + + WIneSettings + + + Wine Settings + + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + Prefix + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Executable + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + diff --git a/rare/utils/rpc.py b/rare/utils/rpc.py index 30ad9e1d..761f7d2a 100644 --- a/rare/utils/rpc.py +++ b/rare/utils/rpc.py @@ -7,6 +7,7 @@ from PyQt5.QtCore import QObject, QSettings from pypresence import Presence from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton +from rare.models.options import options client_id = "830732538225360908" logger = getLogger("RPC") @@ -21,7 +22,7 @@ class DiscordRPC(QObject): self.signals = GlobalSignalsSingleton() self.settings = QSettings() - if self.settings.value("rpc_enable", 0, int) == 1: # show always + if self.settings.value(*options.rpc_enable) == 1: # show always self.state = 2 self.set_discord_rpc() @@ -32,7 +33,7 @@ class DiscordRPC(QObject): self.set_discord_rpc(app_name) def changed_settings(self, game_running: list = None): - value = self.settings.value("rpc_enable", 0, int) + value = self.settings.value(*options.rpc_enable) if value == 2: self.remove_rpc() return @@ -44,7 +45,7 @@ class DiscordRPC(QObject): self.set_discord_rpc(game_running[0]) def remove_rpc(self): - if self.settings.value("rpc_enable", 0, int) != 1: + if self.settings.value(*options.rpc_enable) != 1: if not self.RPC: return try: @@ -85,8 +86,8 @@ class DiscordRPC(QObject): self.update_rpc(app_name) def update_rpc(self, app_name=None): - if self.settings.value("rpc_enable", 0, int) == 2 or ( - not app_name and self.settings.value("rpc_enable", 0, int) == 0 + if self.settings.value(*options.rpc_enable) == 2 or ( + not app_name and self.settings.value(*options.rpc_enable) == 0 ): self.remove_rpc() return @@ -96,17 +97,17 @@ class DiscordRPC(QObject): large_image="logo", details="https://github.com/RareDevs/Rare" ) return - if self.settings.value("rpc_name", True, bool): + if self.settings.value(*options.rpc_name): try: title = self.core.get_installed_game(app_name).title except AttributeError: logger.error(f"Could not get title of game: {app_name}") title = app_name start = None - if self.settings.value("rpc_time", True, bool): + if self.settings.value(*options.rpc_time): start = str(time.time()).split(".")[0] os = None - if self.settings.value("rpc_os", True, bool): + if self.settings.value(*options.rpc_os): os = f"via Rare on {platform.system()}" self.RPC.update( diff --git a/rare/utils/wine.py b/rare/utils/runners/__init__.py similarity index 60% rename from rare/utils/wine.py rename to rare/utils/runners/__init__.py index 306192f0..9b9c9b0d 100644 --- a/rare/utils/wine.py +++ b/rare/utils/runners/__init__.py @@ -1,31 +1,32 @@ import os -import shutil import subprocess from configparser import ConfigParser from logging import getLogger -from typing import Mapping, Dict, List, Tuple +from typing import Mapping, Dict, List, Tuple, Optional -from rare.lgndr.core import LegendaryCore +from rare.utils import config_helper as config +from . import proton +from . import wine -logger = getLogger("Wine") +logger = getLogger("Runners") # this is a copied function from legendary.utils.wine_helpers, but registry file can be specified -def read_registry(registry: str, wine_pfx: str) -> ConfigParser: +def read_registry(registry: str, prefix: str) -> ConfigParser: accepted = ["system.reg", "user.reg"] if registry not in accepted: raise RuntimeError(f'Unknown target "{registry}" not in {accepted}') reg = ConfigParser(comment_prefixes=(';', '#', '/', 'WINE'), allow_no_value=True, strict=False) reg.optionxform = str - reg.read(os.path.join(wine_pfx, 'system.reg')) + reg.read(os.path.join(prefix, 'system.reg')) return reg -def execute(command: List, wine_env: Mapping) -> Tuple[str, str]: +def execute(command: List[str], environment: Mapping) -> Tuple[str, str]: if os.environ.get("container") == "flatpak": flatpak_command = ["flatpak-spawn", "--host"] - for name, value in wine_env.items(): + for name, value in environment.items(): flatpak_command.append(f"--env={name}={value}") command = flatpak_command + command try: @@ -35,7 +36,7 @@ def execute(command: List, wine_env: Mapping) -> Tuple[str, str]: stderr=subprocess.PIPE, # Use the current environment if we are in flatpak or our own if we are on host # In flatpak our environment is passed through `flatpak-spawn` arguments - env=os.environ.copy() if os.environ.get("container") == "flatpak" else wine_env, + env=os.environ.copy() if os.environ.get("container") == "flatpak" else environment, shell=False, text=True, ) @@ -45,13 +46,13 @@ def execute(command: List, wine_env: Mapping) -> Tuple[str, str]: return res -def resolve_path(wine_exec: str, wine_env: Mapping, path: str) -> str: +def resolve_path(command: List[str], environment: Mapping, path: str) -> str: path = path.strip().replace("/", "\\") # lk: if path does not exist form - cmd = [wine_exec, "cmd", "/c", "echo", path] + cmd = command + ["cmd", "/c", "echo", path] # lk: if path exists and needs a case-sensitive interpretation form # cmd = [wine_cmd, 'cmd', '/c', f'cd {path} & cd'] - out, err = execute(cmd, wine_env) + out, err = execute(cmd, environment) out, err = out.strip(), err.strip() if not out: logger.error("Failed to resolve wine path due to \"%s\"", err) @@ -63,9 +64,9 @@ def query_reg_path(wine_exec: str, wine_env: Mapping, reg_path: str): raise NotImplementedError -def query_reg_key(wine_exec: str, wine_env: Mapping, reg_path: str, reg_key) -> str: - cmd = [wine_exec, "reg", "query", reg_path, "/v", reg_key] - out, err = execute(cmd, wine_env) +def query_reg_key(command: List[str], environment: Mapping, reg_path: str, reg_key) -> str: + cmd = command + ["reg", "query", reg_path, "/v", reg_key] + out, err = execute(cmd, environment) out, err = out.strip(), err.strip() if not out: logger.error("Failed to query registry key due to \"%s\"", err) @@ -83,40 +84,23 @@ def convert_to_windows_path(wine_exec: str, wine_env: Mapping, path: str) -> str raise NotImplementedError -def convert_to_unix_path(wine_exec: str, wine_env: Mapping, path: str) -> str: +def convert_to_unix_path(command: List[str], environment: Mapping, path: str) -> str: path = path.strip().strip('"') - cmd = [wine_exec, "winepath.exe", "-u", path] - out, err = execute(cmd, wine_env) + cmd = command + ["winepath.exe", "-u", path] + out, err = execute(cmd, environment) out, err = out.strip(), err.strip() if not out: logger.error("Failed to convert to unix path due to \"%s\"", err) return os.path.realpath(out) if (out := out.strip()) else out -def wine(core: LegendaryCore, app_name: str = "default") -> str: - _wine = core.lgd.config.get( - app_name, "wine_executable", fallback=core.lgd.config.get( - "default", "wine_executable", fallback=shutil.which("wine") - ) - ) - return _wine - - -def environ(core: LegendaryCore, app_name: str = "default") -> Dict: - # Get a clean environment if we are in flatpak, this environment will be pass +def get_environment(app_environment: Dict, silent: bool = True) -> Dict: + # Get a clean environment if we are in flatpak, this environment will be passed # to `flatpak-spawn`, otherwise use the system's. _environ = {} if os.environ.get("container") == "flatpak" else os.environ.copy() - _environ.update(core.get_app_environment(app_name)) - _environ["WINEDEBUG"] = "-all" - _environ["WINEDLLOVERRIDES"] = "winemenubuilder=d;mscoree=d;mshtml=d;" - _environ["DISPLAY"] = "" + _environ.update(app_environment) + if silent: + _environ["WINEDEBUG"] = "-all" + _environ["WINEDLLOVERRIDES"] = "winemenubuilder=d;mscoree=d;mshtml=d;" + _environ["DISPLAY"] = "" return _environ - - -def prefix(core: LegendaryCore, app_name: str = "default") -> str: - _prefix = core.lgd.config.get( - app_name, "wine_prefix", fallback=core.lgd.config.get( - "default", "wine_prefix", fallback=os.path.expanduser("~/.wine") - ) - ) - return _prefix if os.path.isdir(_prefix) else "" diff --git a/rare/utils/proton.py b/rare/utils/runners/proton.py similarity index 76% rename from rare/utils/proton.py rename to rare/utils/runners/proton.py index 717a8f14..2f646796 100644 --- a/rare/utils/proton.py +++ b/rare/utils/runners/proton.py @@ -1,6 +1,8 @@ import platform as pf import os +import shlex from dataclasses import dataclass +from hashlib import md5 from logging import getLogger from typing import Optional, Union, List, Dict @@ -28,11 +30,22 @@ def find_libraries(steam_path: str) -> List[str]: return libraries +# Notes: +# Anything older than 'Proton 5.13' doesn't have the 'require_tool_appid' attribute. +# Anything older than 'Proton 7.0' doesn't have the 'compatmanager_layer_name' attribute. +# In addition to that, the 'Steam Linux Runtime 1.0 (scout)' runtime lists the +# 'Steam Linux Runtime 2.0 (soldier)' runtime as a dependency and is probably what was +# being used for any version before 5.13. +# +# As a result the following implementation will list versions from 7.0 onwards which honestly +# is a good trade-off for the amount of complexity supporting everything would ensue. + + @dataclass class SteamBase: steam_path: str tool_path: str - toolmanifest: dict + toolmanifest: Dict def __eq__(self, other): return self.tool_path == other.tool_path @@ -40,23 +53,36 @@ class SteamBase: def __hash__(self): return hash(self.tool_path) - def commandline(self): - cmd = "".join([f"'{self.tool_path}'", self.toolmanifest["manifest"]["commandline"]]) - cmd = os.path.normpath(cmd) + @property + def required_tool(self) -> Optional[str]: + return self.toolmanifest["manifest"].get("require_tool_appid", None) + + def command(self, setup: bool = False) -> List[str]: + tool_path = os.path.normpath(self.tool_path) + cmd = "".join([shlex.quote(tool_path), self.toolmanifest["manifest"]["commandline"]]) # NOTE: "waitforexitandrun" seems to be the verb used in by steam to execute stuff - cmd = cmd.replace("%verb%", "waitforexitandrun") - return cmd + # `run` is used when setting up the environment, so use that if we are setting up the prefix. + verb = "run" if setup else "waitforexitandrun" + cmd = cmd.replace("%verb%", verb) + return shlex.split(cmd) + + @property + def checksum(self) -> str: + command = " ".join(shlex.quote(part) for part in self.command(setup=False)) + return md5(command.encode("utf-8")).hexdigest() @dataclass class SteamRuntime(SteamBase): steam_library: str - appmanifest: dict + appmanifest: Dict - def name(self): + @property + def name(self) -> str: return self.appmanifest["AppState"]["name"] - def appid(self): + @property + def appid(self) -> str: return self.appmanifest["AppState"]["appid"] @@ -64,33 +90,36 @@ class SteamRuntime(SteamBase): class ProtonTool(SteamRuntime): runtime: SteamRuntime = None - def __bool__(self): - if appid := self.toolmanifest.get("require_tool_appid", False): - return self.runtime is not None and self.runtime.appid() == appid + def __bool__(self) -> bool: + if appid := self.required_tool: + return self.runtime is not None and self.runtime.appid == appid + return True - def commandline(self): - runtime_cmd = self.runtime.commandline() - cmd = super().commandline() - return " ".join([runtime_cmd, cmd]) + def command(self, setup: bool = False) -> List[str]: + cmd = self.runtime.command(setup) + cmd.extend(super().command(setup)) + return cmd @dataclass class CompatibilityTool(SteamBase): - compatibilitytool: dict + compatibilitytool: Dict runtime: SteamRuntime = None - def __bool__(self): - if appid := self.toolmanifest.get("require_tool_appid", False): - return self.runtime is not None and self.runtime.appid() == appid + def __bool__(self) -> bool: + if appid := self.required_tool: + return self.runtime is not None and self.runtime.appid == appid + return True - def name(self): + @property + def name(self) -> str: name, data = list(self.compatibilitytool["compatibilitytools"]["compat_tools"].items())[0] return data["display_name"] - def commandline(self): - runtime_cmd = self.runtime.commandline() if self.runtime is not None else "" - cmd = super().commandline() - return " ".join([runtime_cmd, cmd]) + def command(self, setup: bool = False) -> List[str]: + cmd = self.runtime.command(setup) if self.runtime is not None else [] + cmd.extend(super().command(setup)) + return cmd def find_appmanifests(library: str) -> List[dict]: @@ -187,16 +216,18 @@ def find_runtimes(steam_path: str, library: str) -> Dict[str, SteamRuntime]: def find_runtime( tool: Union[ProtonTool, CompatibilityTool], runtimes: Dict[str, SteamRuntime] ) -> Optional[SteamRuntime]: - required_tool = tool.toolmanifest["manifest"].get("require_tool_appid") + required_tool = tool.required_tool if required_tool is None: return None - return runtimes[required_tool] + return runtimes.get(required_tool, None) -def get_steam_environment(tool: Optional[Union[ProtonTool, CompatibilityTool]], app_name: str = None) -> Dict: - environ = {} +def get_steam_environment( + tool: Optional[Union[ProtonTool, CompatibilityTool]] = None, compat_path: Optional[str] = None +) -> Dict: # If the tool is unset, return all affected env variable names # IMPORTANT: keep this in sync with the code below + environ = {"STEAM_COMPAT_DATA_PATH": compat_path if compat_path else ""} if tool is None: environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = "" environ["STEAM_COMPAT_LIBRARY_PATHS"] = "" @@ -221,7 +252,8 @@ def find_tools() -> List[Union[ProtonTool, CompatibilityTool]]: steam_path = find_steam() logger.debug("Using Steam install in %s", steam_path) steam_libraries = find_libraries(steam_path) - logger.debug("Searching for tools in libraries %s", steam_libraries) + logger.debug("Searching for tools in libraries:") + logger.debug("%s", steam_libraries) runtimes = {} for library in steam_libraries: @@ -236,6 +268,8 @@ def find_tools() -> List[Union[ProtonTool, CompatibilityTool]]: runtime = find_runtime(tool, runtimes) tool.runtime = runtime + tools = list(filter(lambda t: bool(t), tools)) + return tools @@ -247,7 +281,9 @@ if __name__ == "__main__": for tool in _tools: print(get_steam_environment(tool)) - print(tool.name(), tool.commandline()) + print(tool.name) + print(tool.command()) + print(" ".join(tool.command())) def find_proton_combos(): diff --git a/rare/utils/runners/wine.py b/rare/utils/runners/wine.py new file mode 100644 index 00000000..2b87332b --- /dev/null +++ b/rare/utils/runners/wine.py @@ -0,0 +1,88 @@ +import os +from dataclasses import dataclass +from logging import getLogger +from typing import Dict, Tuple, List, Optional + +logger = getLogger("Wine") + +lutris_runtime_paths = [ + os.path.expanduser("~/.local/share/lutris") +] + +__lutris_runtime: str = None +__lutris_wine: str = None + + +def find_lutris() -> Tuple[str, str]: + global __lutris_runtime, __lutris_wine + for path in lutris_runtime_paths: + runtime_path = os.path.join(path, "runtime") + wine_path = os.path.join(path, "runners", "wine") + if os.path.isdir(path) and os.path.isdir(runtime_path) and os.path.isdir(wine_path): + __lutris_runtime, __lutris_wine = runtime_path, wine_path + return runtime_path, wine_path + + +@dataclass +class WineRuntime: + name: str + path: str + environ: Dict + + +@dataclass +class WineRunner: + name: str + path: str + environ: Dict + runtime: Optional[WineRuntime] = None + + +def find_lutris_wines(runtime_path: str = None, wine_path: str = None) -> List[WineRunner]: + runners = [] + if not runtime_path and not wine_path: + return runners + + +def __get_lib_path(executable: str, basename: str = "") -> str: + path = os.path.dirname(os.path.dirname(executable)) + lib32 = os.path.realpath(os.path.join(path, "lib32", basename)) + lib64 = os.path.realpath(os.path.join(path, "lib64", basename)) + lib = os.path.realpath(os.path.join(path, "lib", basename)) + if lib32 == lib or not os.path.exists(lib32): + ldpath = ":".join([lib64, lib]) + elif lib64 == lib or not os.path.exists(lib64): + ldpath = ":".join([lib, lib32]) + else: + ldpath = lib if os.path.exists(lib) else lib64 + return ldpath + + +def get_wine_environment(executable: str = None, prefix: str = None) -> Dict: + # If the tool is unset, return all affected env variable names + # IMPORTANT: keep this in sync with the code below + environ = {"WINEPREFIX": prefix if prefix is not None else ""} + if executable is None: + environ["WINEDLLPATH"] = "" + environ["LD_LIBRARY_PATH"] = "" + else: + winedllpath = __get_lib_path(executable, "wine") + environ["WINEDLLPATH"] = winedllpath + librarypath = __get_lib_path(executable, "") + environ["LD_LIBRARY_PATH"] = librarypath + return environ + + +if __name__ == "__main__": + from pprint import pprint + + pprint(get_wine_environment( + "/opt/wine-ge-custom/bin/wine", None)) + pprint(get_wine_environment( + "/usr/bin/wine", None)) + pprint(get_wine_environment( + "/usr/share/steam/compatitiblitytools.d/dist/bin/wine", None)) + pprint(get_wine_environment( + os.path.expanduser("~/.local/share/Steam/compatibilitytools.d/GE-Proton8-14/files/bin/wine"), None)) + pprint(get_wine_environment( + os.path.expanduser("~/.local/share/lutris/runners/wine/lutris-GE-Proton8-14-x86_64/bin/wine"), None)) diff --git a/rare/utils/steam_grades.py b/rare/utils/steam_grades.py index 54e5e599..69913196 100644 --- a/rare/utils/steam_grades.py +++ b/rare/utils/steam_grades.py @@ -1,6 +1,7 @@ import difflib import os from datetime import datetime +from enum import StrEnum, Enum from typing import Tuple import orjson @@ -18,6 +19,31 @@ __grades_json = None __active_download = False +class ProtondbRatings(int, Enum): + # internal + PENDING = ("pending", -2) + FAIL = ("fail", -1) + # protondb + NA = ("na", 0) + BORKED = ("borked", 1) + BRONZE = ("bronze", 2) + SILVER = ("silver", 3) + GOLD = ("gold", 4) + PLATINUM = ("platinum", 5) + + def __new__(cls, name: str, value: int): + obj = int.__new__(cls, value) + obj._value_ = value + obj._name_ = name + return obj + + def __str__(self): + return self._name_ + + def __int__(self): + return self._value_ + + def get_rating(core: LegendaryCore, app_name: str) -> Tuple[int, str]: game = core.get_game(app_name) try: @@ -31,7 +57,7 @@ def get_rating(core: LegendaryCore, app_name: str) -> Tuple[int, str]: return steam_id, grade -# you should iniciate the module with the game's steam code +# you should initiate the module with the game's steam code def get_grade(steam_code): if steam_code == 0: return "fail"