diff --git a/rare/components/tabs/games/__init__.py b/rare/components/tabs/games/__init__.py index c206b4e31..fc161c36c 100644 --- a/rare/components/tabs/games/__init__.py +++ b/rare/components/tabs/games/__init__.py @@ -14,7 +14,7 @@ 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 @@ -33,8 +33,6 @@ def __init__(self, parent=None): self.image_manager = ImageManagerSingleton() self.settings = QSettings() - self.active_filter: int = 0 - self.games_page = QWidget(parent=self) games_page_layout = QVBoxLayout(self.games_page) self.addWidget(self.games_page) @@ -94,14 +92,11 @@ def __init__(self, parent=None): 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) - f = self.settings.value("filter", 0, int) - if f >= len(self.head_bar.available_filters): - f = 0 - self.active_filter = self.head_bar.available_filters[f] - # signals self.signals.game.installed.connect(self.update_count_games_label) self.signals.game.uninstalled.connect(self.update_count_games_label) @@ -153,7 +148,7 @@ def setup_game_list(self): 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): @@ -167,18 +162,26 @@ def add_library_widget(self, rgame: RareGame): 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_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.filter_list(filter_name, search_text.lower()) + 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 a39d492b6..86bac7aa9 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 @@ 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 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_widget import Ui_CloudWidget from rare.ui.components.tabs.games.game_info.sync_widget import Ui_SyncWidget 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 @@ -110,13 +111,14 @@ def download(self): 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") @@ -125,21 +127,21 @@ def compute_save_path(self): 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) self.compute_save_path_button.setDisabled(False) if path and not os.path.exists(path): try: - os.makedirs(path) + os.makedirs(path, exist_ok=True) except PermissionError: self.cloud_save_path_edit.setText("") QMessageBox.warning( @@ -154,8 +156,6 @@ def wine_resolver_finished(self, path, app_name): 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_widgets/__init__.py b/rare/components/tabs/games/game_widgets/__init__.py index b1df4b3dc..62da8c3f2 100644 --- a/rare/components/tabs/games/game_widgets/__init__.py +++ b/rare/components/tabs/games/game_widgets/__init__.py @@ -1,4 +1,5 @@ -from typing import Tuple, List, Union, Optional +from enum import IntEnum +from typing import Tuple, List, Union from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtWidgets import QWidget @@ -11,6 +12,24 @@ from .list_game_widget import ListGameWidget +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 + + class LibraryWidgetController(QObject): def __init__(self, icon_container: QWidget, list_container: QWidget, parent: QWidget = None): super(LibraryWidgetController, self).__init__(parent=parent) @@ -20,8 +39,8 @@ def __init__(self, icon_container: QWidget, list_container: QWidget, parent: QWi 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 +51,26 @@ def add_widgets(self, rgame: RareGame) -> Tuple[IconGameWidget, ListGameWidget]: 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 - elif filter_name == "offline": + elif library_filter == LibraryFilter.OFFLINE: visible = widget.rgame.can_run_offline - elif filter_name == "32bit": + elif library_filter == LibraryFilter.WIN32: visible = widget.rgame.is_win32 - elif filter_name == "mac": + elif library_filter == LibraryFilter.MAC: visible = widget.rgame.is_mac - elif filter_name == "installable": + elif library_filter == LibraryFilter.INSTALLABLE: visible = not widget.rgame.is_non_asset - 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 +85,7 @@ def __visibility(widget: Union[IconGameWidget,ListGameWidget], filter_name, sear 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 +96,52 @@ def filter_list(self, filter_name="all", search_text: str = ""): 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: + def order_game_views(self, order_by: LibraryOrder = LibraryOrder.TITLE, search_text: str = ""): + list_widgets = self._list_container.findChildren(ListGameWidget) + if search_text: 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, - ) + lambda x: (search_text not in x.widget().rgame.app_title.lower(),) ) - 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(),)) + list_widgets.sort(key=lambda x: (search_text not in x.rgame.app_title.lower(),)) else: - list_widgets.sort( + if (newest := order_by == LibraryOrder.NEWEST) or order_by == LibraryOrder.OLDEST: # 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) - ) + 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 +160,7 @@ def update_list(self, app_names: List[str] = None): 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 1178108c2..b61c1e5a4 100644 --- a/rare/components/tabs/games/head_bar.py +++ b/rare/components/tabs/games/head_bar.py @@ -1,20 +1,24 @@ -from PyQt5.QtCore import QSettings, pyqtSignal, pyqtSlot +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.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() @@ -24,61 +28,65 @@ def __init__(self, parent=None): self.rcore = RareCore.instance() self.settings = QSettings() - self.filter = QComboBox() - self.filter.addItems( - [ - self.tr("All games"), - self.tr("Installed only"), - self.tr("Offline Games"), - # self.tr("Hidden") - ] - ) - - self.available_filters = [ - "all", - "installed", - "offline", - # "hidden" - ] + self.filter = QComboBox(parent=self) + filters = { + LibraryFilter.ALL: self.tr("All games"), + LibraryFilter.INSTALLED: self.tr("Installed only"), + LibraryFilter.OFFLINE: self.tr("Offline Games"), + # LibraryFilterTypes.HIDDEN: self.tr("Hidden"), + } + for data, text in filters.items(): + self.filter.addItem(text, data) if self.rcore.bit32_games: - self.filter.addItem(self.tr("32 Bit Games")) - self.available_filters.append("32bit") - + self.filter.addItem(self.tr("32 Bit Games"), LibraryFilter.WIN32) if self.rcore.mac_games: - self.filter.addItem(self.tr("Mac games")) - self.available_filters.append("mac") - + self.filter.addItem(self.tr("Mac games"), LibraryFilter.MAC) if self.rcore.origin_games: - self.filter.addItem(self.tr("Exclude Origin")) - self.available_filters.append("installable") - - self.filter.addItem(self.tr("Include Unreal Engine")) - self.available_filters.append("include_ue") + self.filter.addItem(self.tr("Exclude Origin"), LibraryFilter.INSTALLABLE) + self.filter.addItem(self.tr("Include Unreal Engine"), LibraryFilter.INCLUDE_UE) try: - self.filter.setCurrentIndex(self.settings.value("filter", 0, int)) - except TypeError: - self.settings.setValue("filter", 0) - self.filter.setCurrentIndex(0) - - 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.filter.setCurrentIndex( + self.filter.findData( + LibraryFilter(self.settings.value("library_filter", int(LibraryFilter.ALL), int)) + ) + ) + except (TypeError, ValueError): + self.settings.setValue("library_filter", int(LibraryFilter.ALL)) + self.filter.setCurrentIndex(self.filter.findData(LibraryFilter.ALL)) + + self.filter.currentIndexChanged.connect(self.__filter_changed) + + 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) + 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) @@ -91,8 +99,8 @@ def __init__(self, parent=None): 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() @@ -100,24 +108,25 @@ def __init__(self, parent=None): 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) @@ -128,17 +137,29 @@ def __init__(self, parent=None): 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): + data = int(self.filter.itemData(index, Qt.UserRole)) + self.filterChanged.emit(data) + self.settings.setValue("library_filter", data) + + def current_order(self) -> int: + return int(self.order.currentData(Qt.UserRole)) + @pyqtSlot(int) - def filter_changed(self, i: int): - self.filterChanged.emit(self.available_filters[i]) - self.settings.setValue("filter", i) + def __order_changed(self, index: int): + data = int(self.order.itemData(index, Qt.UserRole)) + self.orderChanged.emit(data) + self.settings.setValue("library_order", data) diff --git a/rare/components/tabs/games/integrations/__init__.py b/rare/components/tabs/games/integrations/__init__.py index 0ecbc0880..0e9eb6bb2 100644 --- a/rare/components/tabs/games/integrations/__init__.py +++ b/rare/components/tabs/games/integrations/__init__.py @@ -1,7 +1,7 @@ from typing import Optional from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QVBoxLayout, QWidget, QLabel, QSpacerItem, QSizePolicy +from PyQt5.QtWidgets import QVBoxLayout, QWidget, QLabel, QSizePolicy from rare.widgets.side_tab import SideTabWidget from .egl_sync_group import EGLSyncGroup @@ -34,8 +34,8 @@ def __init__(self, parent=None): self.tr(""), self, ) - self.ubisoft_group = UbisoftGroup(self.eos_ubisoft) self.eos_group = EosGroup(self.eos_ubisoft) + self.ubisoft_group = UbisoftGroup(self.eos_ubisoft) self.eos_ubisoft.addWidget(self.eos_group) self.eos_ubisoft.addWidget(self.ubisoft_group) self.eos_ubisoft_index = self.addTab(self.eos_ubisoft, self.tr("Epic Overlay and Ubisoft")) diff --git a/rare/components/tabs/games/integrations/egl_sync_group.py b/rare/components/tabs/games/integrations/egl_sync_group.py index 94970641b..f3842c872 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 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 @@ -56,16 +57,9 @@ def __init__(self, parent=None): self.egl_path_info.setEnabled(False) else: self.egl_path_edit.textChanged.connect(self.egl_path_changed) - if not self.core.egl.programdata_path: - self.egl_path_info.setText(self.tr("Updating...")) - wine_resolver = WineResolver( - self.core, PathSpec.egl_programdata, "default" - ) - wine_resolver.signals.result_ready.connect(self.wine_resolver_cb) - QThreadPool.globalInstance().start(wine_resolver) - else: - self.egl_path_info_label.setVisible(False) - self.egl_path_info.setVisible(False) + if self.core.egl.programdata_path: + self.egl_path_info_label.setEnabled(True) + self.egl_path_info.setEnabled(True) self.ui.egl_sync_check.setChecked(self.core.egl_sync_enabled) self.ui.egl_sync_check.stateChanged.connect(self.egl_sync_changed) @@ -79,10 +73,24 @@ def __init__(self, parent=None): # self.egl_watcher.directoryChanged.connect(self.update_lists) def showEvent(self, a0: QShowEvent) -> None: + if a0.spontaneous(): + return super().showEvent(a0) + if not self.core.egl.programdata_path: + self.__run_wine_resolver() self.update_lists() super().showEvent(a0) - def wine_resolver_cb(self, path): + def __run_wine_resolver(self): + self.egl_path_info.setText(self.tr("Updating...")) + 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) + + def __on_wine_resolver_result(self, path): self.egl_path_info.setText(path) if not path: self.egl_path_info.setText( @@ -109,14 +117,8 @@ def egl_path_edit_edit_cb(path) -> Tuple[bool, str, int]: 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): @@ -166,13 +168,15 @@ def egl_sync_changed(self, state): def update_lists(self): # self.egl_watcher.blockSignals(True) - if have_path := bool(self.core.egl.programdata_path) and os.path.exists(self.core.egl.programdata_path): + have_path = False + if self.core.egl.programdata_path: + have_path = os.path.exists(self.core.egl.programdata_path) + if not have_path and os.path.isdir(os.path.dirname(self.core.egl.programdata_path)): + # NOTE: This might happen if EGL is installed but no games have been installed through it + os.mkdir(self.core.egl.programdata_path) + have_path = os.path.isdir(self.core.egl.programdata_path) # NOTE: need to clear known manifests to force refresh self.core.egl.manifests.clear() - elif os.path.isdir(os.path.dirname(self.core.egl.programdata_path)): - # NOTE: This might happen if EGL is installed but no games have been installed through it - os.mkdir(self.core.egl.programdata_path) - have_path = bool(self.core.egl.programdata_path) and os.path.isdir(self.core.egl.programdata_path) self.ui.egl_sync_check_label.setEnabled(have_path) self.ui.egl_sync_check.setEnabled(have_path) self.import_list.populate(have_path) diff --git a/rare/components/tabs/games/integrations/eos_group.py b/rare/components/tabs/games/integrations/eos_group.py index 7cd8c1633..bd050201c 100644 --- a/rare/components/tabs/games/integrations/eos_group.py +++ b/rare/components/tabs/games/integrations/eos_group.py @@ -20,7 +20,7 @@ from rare.models.game import RareEosOverlay from rare.shared import RareCore from rare.ui.components.tabs.games.integrations.eos_widget import Ui_EosWidget -from rare.utils import config_helper +from rare.utils import config_helper as config from rare.utils.misc import icon from rare.widgets.elide_label import ElideLabel @@ -51,7 +51,10 @@ def __init__(self, overlay: RareEosOverlay, prefix: Optional[str], parent=None): self.indicator = QLabel(parent=self) self.indicator.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) - self.prefix_label = ElideLabel(prefix if prefix is not None else overlay.app_title, parent=self) + self.prefix_label = ElideLabel( + prefix.replace(os.path.expanduser("~"), "~") if prefix is not None else overlay.app_title, + parent=self, + ) self.overlay_label = ElideLabel(parent=self) self.overlay_label.setDisabled(True) @@ -128,10 +131,14 @@ def action(self) -> None: if self.overlay.is_enabled(self.prefix) and (path == active_path): if not self.overlay.disable(prefix=self.prefix): QMessageBox.warning( - self, "Warning", + self, + "Warning", self.tr("Failed to completely disable the active EOS Overlay.{}").format( - self.tr(" Since the previous overlay was managed by EGL you can safely ignore this is.") - if active_path != install_path else "" + self.tr( + " Since the previous overlay was managed by EGL you can safely ignore this is." + ) + if active_path != install_path + else "" ), ) else: @@ -141,7 +148,9 @@ def action(self) -> None: self, "Warning", self.tr("Failed to completely enable EOS overlay.{}").format( - self.tr(" Since the previous overlay was managed by EGL you can safely ignore this is.") + self.tr( + " Since the previous overlay was managed by EGL you can safely ignore this is." + ) if active_path != install_path else "" ), @@ -191,8 +200,11 @@ def __init__(self, parent=None): self.ui.update_button.setEnabled(False) self.threadpool = QThreadPool.globalInstance() + self.worker: Optional[CheckForUpdateWorker] = None def showEvent(self, a0) -> None: + if a0.spontaneous(): + return super().showEvent(a0) self.check_for_update() self.update_prefixes() super().showEvent(a0) @@ -202,7 +214,8 @@ def update_prefixes(self): widget.deleteLater() if platform.system() != "Windows": - prefixes = config_helper.get_wine_prefixes() + prefixes = config.get_prefixes() + prefixes = {prefix for prefix in prefixes if config.prefix_exists(prefix)} if platform.system() == "Darwin": # TODO: add crossover support pass @@ -214,16 +227,21 @@ def update_prefixes(self): widget = EosPrefixWidget(self.overlay, None) self.ui.eos_layout.addWidget(widget) + @pyqtSlot(bool) + def worker_finished(self, update_available: bool): + self.worker = None + self.ui.update_button.setEnabled(update_available) + def check_for_update(self): if not self.overlay.is_installed: return - def worker_finished(update_available): - self.ui.update_button.setEnabled(update_available) + if self.worker is not None: + return - worker = CheckForUpdateWorker(self.core) - worker.signals.update_available.connect(worker_finished) - QThreadPool.globalInstance().start(worker) + self.worker = CheckForUpdateWorker(self.core) + self.worker.signals.update_available.connect(self.worker_finished) + QThreadPool.globalInstance().start(self.worker) @pyqtSlot() def install_finished(self): diff --git a/rare/components/tabs/games/integrations/ubisoft_group.py b/rare/components/tabs/games/integrations/ubisoft_group.py index ca4070d36..eddd303df 100644 --- a/rare/components/tabs/games/integrations/ubisoft_group.py +++ b/rare/components/tabs/games/integrations/ubisoft_group.py @@ -5,18 +5,25 @@ from PyQt5.QtCore import QObject, pyqtSignal, QThreadPool, QSize, pyqtSlot, Qt from PyQt5.QtGui import QShowEvent -from PyQt5.QtWidgets import QFrame, QLabel, QHBoxLayout, QSizePolicy, QPushButton, QGroupBox, QVBoxLayout +from PyQt5.QtWidgets import ( + QFrame, + QLabel, + QHBoxLayout, + QSizePolicy, + QPushButton, + QGroupBox, + QVBoxLayout, +) from legendary.models.game import Game from rare.lgndr.core import LegendaryCore from rare.shared import RareCore from rare.shared.workers.worker import Worker +from rare.utils.metrics import timelogger from rare.utils.misc import icon from rare.widgets.elide_label import ElideLabel from rare.widgets.loading_widget import LoadingWidget -from rare.utils.metrics import timelogger - logger = getLogger("Ubisoft") @@ -78,9 +85,7 @@ def run_real(self) -> None: self.signals.linked.emit("") return try: - self.core.egs.store_claim_uplay_code( - self.ubi_account_id, self.partner_link_id - ) + self.core.egs.store_claim_uplay_code(self.ubi_account_id, self.partner_link_id) self.core.egs.store_redeem_uplay_codes(self.ubi_account_id) except Exception as e: self.signals.linked.emit(str(e)) @@ -112,9 +117,7 @@ def __init__(self, game: Game, ubi_account_id, activated: bool = False, parent=N if activated: self.link_button.setText(self.tr("Already activated")) self.link_button.setDisabled(True) - self.ok_indicator.setPixmap( - icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)) - ) + self.ok_indicator.setPixmap(icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20))) layout = QHBoxLayout(self) layout.setContentsMargins(-1, 0, 0, 0) @@ -125,28 +128,24 @@ def __init__(self, game: Game, ubi_account_id, activated: bool = False, parent=N def activate(self): self.link_button.setDisabled(True) # self.ok_indicator.setPixmap(icon("mdi.loading", color="grey").pixmap(20, 20)) - self.ok_indicator.setPixmap( - icon("mdi.transit-connection-horizontal", color="grey").pixmap(20, 20) - ) + self.ok_indicator.setPixmap(icon("mdi.transit-connection-horizontal", color="grey").pixmap(20, 20)) if self.args.debug: worker = UbiConnectWorker(RareCore.instance().core(), None, None) else: - worker = UbiConnectWorker(RareCore.instance().core(), self.ubi_account_id, self.game.partner_link_id) + worker = UbiConnectWorker( + RareCore.instance().core(), self.ubi_account_id, self.game.partner_link_id + ) worker.signals.linked.connect(self.worker_finished) QThreadPool.globalInstance().start(worker) def worker_finished(self, error): if not error: - self.ok_indicator.setPixmap( - icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20)) - ) + self.ok_indicator.setPixmap(icon("fa.check-circle-o", color="green").pixmap(QSize(20, 20))) self.link_button.setDisabled(True) self.link_button.setText(self.tr("Already activated")) else: - self.ok_indicator.setPixmap( - icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20)) - ) + self.ok_indicator.setPixmap(icon("fa.times-circle-o", color="red").pixmap(QSize(20, 20))) self.ok_indicator.setToolTip(error) self.link_button.setText(self.tr("Try again")) self.link_button.setDisabled(False) @@ -203,7 +202,9 @@ def showEvent(self, a0: QShowEvent) -> None: def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): self.worker = None if not redeemed and ubi_account_id != "error": - logger.error("No linked ubisoft account found! Link your accounts via your browser and try again.") + logger.error( + "No linked ubisoft account found! Link your accounts via your browser and try again." + ) self.info_label.setText( self.tr("Your account is not linked with Ubisoft. Please link your account and try again.") ) @@ -228,9 +229,7 @@ def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): except (IndexError, KeyError): app_name = "unknown" - dlc_game = Game( - app_name=app_name, app_title=dlc_data["title"], metadata=dlc_data - ) + dlc_game = Game(app_name=app_name, app_title=dlc_data["title"], metadata=dlc_data) if dlc_game.partner_link_type != "ubisoft": continue if dlc_game.partner_link_id in redeemed: @@ -244,24 +243,24 @@ def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): uplay_games.append(game) if not uplay_games: - self.info_label.setText( - self.tr("You don't own any Ubisoft games.") - ) + self.info_label.setText(self.tr("You don't own any Ubisoft games.")) else: if activated == len(uplay_games): - self.info_label.setText( - self.tr("All your Ubisoft games have already been activated.") - ) + self.info_label.setText(self.tr("All your Ubisoft games have already been activated.")) else: self.info_label.setText( - self.tr("You have {} games available to redeem.").format(len(uplay_games) - activated) + self.tr("You have {} games available to redeem.").format( + len(uplay_games) - activated + ) ) logger.info(f"Found {len(uplay_games) - activated} game(s) to redeem.") self.loading_widget.stop() for game in uplay_games: - widget = UbiLinkWidget(game, ubi_account_id, activated=game.partner_link_id in redeemed, parent=self) + widget = UbiLinkWidget( + game, ubi_account_id, activated=game.partner_link_id in redeemed, parent=self + ) self.layout().addWidget(widget) if self.args.debug: @@ -269,7 +268,7 @@ def show_ubi_games(self, redeemed: set, entitlements: set, ubi_account_id: str): Game(app_name="RareTestGame", app_title="Super Fake Super Rare Super Test (Super?) Game"), ubi_account_id, activated=False, - parent=self + parent=self, ) self.layout().addWidget(widget) self.browser_button.setEnabled(True) diff --git a/rare/components/tabs/settings/game_settings.py b/rare/components/tabs/settings/game_settings.py index 201f5fee1..ea012dd4d 100644 --- a/rare/components/tabs/settings/game_settings.py +++ b/rare/components/tabs/settings/game_settings.py @@ -2,10 +2,7 @@ from logging import getLogger from PyQt5.QtCore import QSettings, Qt -from PyQt5.QtWidgets import ( - QWidget, - QLabel -) +from PyQt5.QtWidgets import QWidget, QLabel from rare.components.tabs.settings.widgets.env_vars import EnvVars from rare.components.tabs.settings.widgets.linux import LinuxSettings @@ -31,20 +28,18 @@ def __init__(self, is_default, parent=None): self.wrapper_settings = WrapperSettings() - self.ui.launch_settings_group.layout().addRow( - QLabel("Wrapper"), self.wrapper_settings - ) + 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() - self.proton_settings = ProtonSettings(self.linux_settings, self.wrapper_settings) + self.linux_settings = LinuxAppSettings(self) + self.proton_settings = ProtonSettings(self) self.ui.proton_layout.addWidget(self.proton_settings) # FIXME: Remove the spacerItem and margins from the linux settings - # FIXME: This should be handled differently at soem point in the future + # FIXME: This should be handled differently at some point in the future # NOTE: specerItem has been removed self.linux_settings.layout().setContentsMargins(0, 0, 0, 0) # FIXME: End of FIXME @@ -53,11 +48,10 @@ def __init__(self, is_default, parent=None): 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) self.proton_settings.environ_changed.connect(self.env_vars.reset_model) + self.proton_settings.tool_enabled.connect(self.wrapper_settings.update_state) + self.proton_settings.tool_enabled.connect(self.linux_settings.tool_enabled) else: self.ui.linux_settings_widget.setVisible(False) @@ -73,27 +67,29 @@ 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 - self.proton_settings.load_settings(app_name, proton) - if proton: - self.linux_settings.ui.wine_groupbox.setEnabled(False) - else: - self.linux_settings.ui.wine_groupbox.setEnabled(True) + self.linux_settings.load_settings(app_name) + # proton = self.wrapper_settings.wrappers.get("proton", "") + # if proton: + # proton = proton.text + self.proton_settings.load_settings(app_name) + # proton = False + # 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) class LinuxAppSettings(LinuxSettings): - def __init__(self): - super(LinuxAppSettings, self).__init__() + def __init__(self, parent=None): + super(LinuxAppSettings, self).__init__(parent=parent) + + def load_settings(self, app_name): + self.app_name = app_name - 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.wine_exec.setText(self.load_setting(self.app_name, "wine_executable")) - self.dxvk.load_settings(self.name) + self.dxvk.load_settings(self.app_name) - self.mangohud.load_settings(self.name) + self.mangohud.load_settings(self.app_name) diff --git a/rare/components/tabs/settings/widgets/env_vars_model.py b/rare/components/tabs/settings/widgets/env_vars_model.py index 795533e7d..7f2f9fce6 100644 --- a/rare/components/tabs/settings/widgets/env_vars_model.py +++ b/rare/components/tabs/settings/widgets/env_vars_model.py @@ -8,11 +8,12 @@ from rare.lgndr.core import LegendaryCore from rare.utils.misc import icon -from rare.utils import proton +from rare.utils.runners.proton import get_steam_environment +from rare.utils.runners.wine import get_wine_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 @@ -23,12 +24,11 @@ def __init__(self, core: LegendaryCore, parent = None): self.__data_map: ChainMap = ChainMap() self.__readonly = [ - "STEAM_COMPAT_DATA_PATH", - "WINEPREFIX", "DXVK_HUD", "MANGOHUD_CONFIG", ] - self.__readonly.extend(proton.get_steam_environment(None).keys()) + self.__readonly.extend(get_steam_environment().keys()) + self.__readonly.extend(get_wine_environment().keys()) self.__default: str = "default" self.__appname: str = None @@ -250,8 +250,6 @@ def removeRow(self, row: int, parent: QModelIndex = None) -> bool: 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/linux.py b/rare/components/tabs/settings/widgets/linux.py index b3a322bd4..2b7628df9 100644 --- a/rare/components/tabs/settings/widgets/linux.py +++ b/rare/components/tabs/settings/widgets/linux.py @@ -1,16 +1,15 @@ import os -import shutil from logging import getLogger -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QFileDialog, QWidget 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.shared.rare_core import RareCore from rare.ui.components.tabs.settings.linux import Ui_LinuxSettings from rare.widgets.indicator_edit import PathEdit, IndicatorReasonsCommon -from rare.utils import config_helper +from rare.utils import config_helper as config logger = getLogger("LinuxSettings") @@ -19,15 +18,15 @@ class LinuxSettings(QWidget): # str: option key environ_changed = pyqtSignal(str) - def __init__(self, name=None, parent=None): + def __init__(self, app_name: str = None, parent=None): super(LinuxSettings, self).__init__(parent=parent) self.ui = Ui_LinuxSettings() self.ui.setupUi(self) - self.core = LegendaryCoreSingleton() - self.signals = GlobalSignalsSingleton() + self.core = RareCore.instance().core() + self.signals = RareCore.instance().signals() - self.name = name if name is not None else "default" + self.app_name = app_name if app_name is not None else "default" # Wine prefix self.wine_prefix = PathEdit( @@ -40,12 +39,12 @@ def __init__(self, name=None, parent=None): # 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) @@ -54,34 +53,45 @@ def __init__(self, name=None, parent=None): 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.dxvk.load_settings(self.app_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.mangohud.load_settings(self.app_name) + + @pyqtSlot(bool) + def tool_enabled(self, enabled: bool): + if enabled: + config.remove_option(self.app_name, "no_wine") + else: + config.add_option(self.app_name, "no_wine", "true") + self.ui.wine_groupbox.setEnabled(not enabled) + self.wine_exec.setText("") + self.wine_prefix.setText("") 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) + config.add_option(section, setting, text) logger.debug(f"Set {setting} in {f'[{section}]'} to {text}") else: - config_helper.remove_option(section, setting) + config.remove_option(section, setting) logger.debug(f"Unset {setting} from {f'[{section}]'}") - config_helper.save_config() + config.save_config() diff --git a/rare/components/tabs/settings/widgets/mangohud.py b/rare/components/tabs/settings/widgets/mangohud.py index e99570aaa..fd26ea469 100644 --- a/rare/components/tabs/settings/widgets/mangohud.py +++ b/rare/components/tabs/settings/widgets/mangohud.py @@ -13,7 +13,6 @@ class MangoHudSettings(OverlaySettings): - set_wrapper_activated = pyqtSignal(bool) def __init__(self): diff --git a/rare/components/tabs/settings/widgets/proton.py b/rare/components/tabs/settings/widgets/proton.py index 658aaf872..0a6237659 100644 --- a/rare/components/tabs/settings/widgets/proton.py +++ b/rare/components/tabs/settings/widgets/proton.py @@ -1,85 +1,107 @@ 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.proton_prefix.setEnabled(False) - self.proton_prefix.setText("") - - self._linux_settings.ui.wine_groupbox.setEnabled(True) + self.app_name: str = "default" + self.core = RareCore.instance().core() + self.wrappers: Wrappers = RareCore.instance().wrappers() + self.tool_wrapper: Optional[Wrapper] = None + + 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.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) + self.tool_enabled.emit(enabled) + super().showEvent(a0) + + def __on_proton_changed(self, index): + steam_tool: Union[proton.ProtonTool, proton.CompatibilityTool] = self.ui.proton_combo.itemData(index) + + 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) + + 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: - 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")) + wrapper = Wrapper( + command=steam_tool.command(), name=steam_tool.name, wtype=WrapperType.COMPAT_TOOL ) - self.environ_changed.emit("STEAM_COMPAT_CLIENT_INSTALL_PATH") + wrappers.append(wrapper) + self.tool_wrapper = wrapper + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) - self.proton_prefix.setText(os.path.expanduser("~/.proton")) + self.proton_prefix.setEnabled(steam_tool is not None) + self.proton_prefix.setText(os.path.expanduser("~/.proton") if steam_tool is not None else "") - # Don't use Wine - self._linux_settings.wine_exec.setText("") - self._linux_settings.wine_prefix.setText("") + self.tool_enabled.emit(steam_tool is not None) + config.save_config() - config_helper.save_config() - - def proton_prefix_edit(self, text: str) -> Tuple[bool, str, int]: + @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 +110,9 @@ def proton_prefix_edit(self, text: str) -> Tuple[bool, str, int]: 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/wrapper.py b/rare/components/tabs/settings/widgets/wrapper.py index b8b35c435..f34b5fa1e 100644 --- a/rare/components/tabs/settings/widgets/wrapper.py +++ b/rare/components/tabs/settings/widgets/wrapper.py @@ -1,9 +1,10 @@ -import re +import shlex +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 +17,41 @@ 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 +68,8 @@ def __init__(self, text: str, show_text=None, parent=None): 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 +81,39 @@ def __init__(self, text: str, show_text=None, parent=None): 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") + def data(self) -> Wrapper: + return self.wrapper + @pyqtSlot() - def __delete(self): - self.delete_wrapper.emit(self.text) + 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) @@ -119,25 +126,17 @@ def __init__(self): 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 +148,22 @@ def __init__(self): self.wrapper_scroll.verticalScrollBar().setObjectName( f"{self.wrapper_scroll.objectName()}Bar") + 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 +173,183 @@ def adjust_scrollarea(self, min: int, max: int): - self.wrapper_scroll.contentsRect().height() ) - def get_wrapper_string(self): - return " ".join(self.get_wrapper_list()) - - 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] + @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 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) - - self.wrappers[show_text] = widget + widget.update_wrapper.connect(self.__update_wrapper) + widget.delete_wrapper.connect(self.__delete_wrapper) - if not from_load: - self.save() - - @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() - - if not self.wrappers: - self.wrapper_scroll.setMaximumHeight(self.ui.label_page.sizeHint().height()) - self.ui.widget_stack.setCurrentIndex(1) + 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) - self.save() + def add_user_wrapper(self, wrapper: Wrapper, position: int = -1): + if not wrapper: + return - @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) + 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 - 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()) + # 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 - def load_settings(self, app_name: str): - self.app_name = app_name - for i in self.wrappers.values(): - i.deleteLater() - self.wrappers.clear() + 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 - wrappers = self.settings.value(f"{self.app_name}/wrapper", [], str) + self.add_wrapper(wrapper, position) - 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] + @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) - for wrapper in wrappers: - self.add_wrapper(wrapper, from_load=True) + @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) - 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/lgndr/cli.py b/rare/lgndr/cli.py index 54c9262db..49e2747b9 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -6,6 +6,7 @@ from typing import Optional, Union, Tuple from legendary.cli import LegendaryCLI as LegendaryCLIReal +from legendary.lfs.wine_helpers import case_insensitive_file_search from legendary.models.downloading import AnalysisResult, ConditionCheckResult from legendary.models.game import Game, InstalledGame, VerifyResult from legendary.lfs.utils import validate_files @@ -535,6 +536,8 @@ def import_game(self, args: LgndrImportGameArgs) -> None: # get everything needed for import from core, then run additional checks. manifest, igame = self.core.import_game(game, args.app_path, platform=args.platform) exe_path = os.path.join(args.app_path, manifest.meta.launch_exe.lstrip('/')) + if os.name != 'nt': + exe_path = case_insensitive_file_search(exe_path) # check if most files at least exist or if user might have specified the wrong directory total = len(manifest.file_manifest_list.elements) found = sum(os.path.exists(os.path.join(args.app_path, f.filename)) diff --git a/rare/lgndr/downloader/mp/manager.py b/rare/lgndr/downloader/mp/manager.py index 9a7836683..2168f4b4f 100644 --- a/rare/lgndr/downloader/mp/manager.py +++ b/rare/lgndr/downloader/mp/manager.py @@ -72,7 +72,7 @@ def run_real(self): self.conditions = [shm_cond, task_cond] # start threads - s_time = time.time() + s_time = time.perf_counter() self.threads.append(Thread(target=self.download_job_manager, args=(task_cond, shm_cond))) self.threads.append(Thread(target=self.dl_results_handler, args=(task_cond,))) self.threads.append(Thread(target=self.fw_results_handler, args=(shm_cond,))) @@ -80,13 +80,13 @@ def run_real(self): for t in self.threads: t.start() - last_update = time.time() + last_update = time.perf_counter() # Rare: kill requested kill_request = False while processed_tasks < num_tasks: - delta = time.time() - last_update + delta = time.perf_counter() - last_update if not delta: time.sleep(self.update_interval) continue @@ -108,10 +108,10 @@ def run_real(self): self.bytes_read_since_last = self.bytes_written_since_last = 0 self.bytes_downloaded_since_last = self.num_processed_since_last = 0 self.bytes_decompressed_since_last = self.num_tasks_processed_since_last = 0 - last_update = time.time() + last_update = time.perf_counter() perc = (processed_chunks / num_chunk_tasks) * 100 - runtime = time.time() - s_time + runtime = time.perf_counter() - s_time total_avail = len(self.sms) total_used = (num_shared_memory_segments - total_avail) * (self.analysis.biggest_chunk / 1024 / 1024) diff --git a/rare/main.py b/rare/main.py index 54c2d661e..74d67fd93 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" # fix cx_freeze multiprocessing.freeze_support() diff --git a/rare/models/game.py b/rare/models/game.py index dd4dea655..96ea63f6a 100644 --- a/rare/models/game.py +++ b/rare/models/game.py @@ -2,7 +2,7 @@ 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 @@ -31,7 +31,7 @@ class Metadata: 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 @@ -43,7 +43,7 @@ def from_dict(cls, data: Dict): queued=data.get("queued", False), queue_pos=data.get("queue_pos", 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 None, + 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(x) if (x := data.get("steam_date", None)) else datetime.min, @@ -56,7 +56,7 @@ def __dict__(self): 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, @@ -80,8 +80,7 @@ def __init__(self, legendary_core: LegendaryCore, image_manager: ImageManager, g 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() @@ -479,17 +478,17 @@ def set_steam_grade(self, appid: int, grade: str) -> None: 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/pathspec.py b/rare/models/pathspec.py index 24c9d1a5e..5249476ef 100644 --- a/rare/models/pathspec.py +++ b/rare/models/pathspec.py @@ -2,46 +2,72 @@ 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 - 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_appdata() -> str: + return r"%LOCALAPPDATA%\EpicGamesLauncher\Saved\Config\Windows" + + @staticmethod + def egl_programdata() -> str: + return r"%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests" + + @staticmethod + def wine_programdata() -> str: + return r"ProgramData" + + @staticmethod + def wine_egl_programdata() -> str: + return PathSpec.egl_programdata( + ).replace( + "\\", "/" + ).replace( + "%PROGRAMDATA%", PathSpec.wine_programdata() + ) - @property - def wine_egl_programdata(self): - return self.egl_programdata.replace("\\", "/").replace("%PROGRAMDATA%", self.wine_programdata) + @staticmethod + def prefix_egl_programdata(prefix: str) -> str: + return os.path.join(prefix, "dosdevices/c:", PathSpec.wine_egl_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_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 000000000..09a0fc9e2 --- /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/game_process.py b/rare/shared/game_process.py index 784b2f9af..be69f335b 100644 --- a/rare/shared/game_process.py +++ b/rare/shared/game_process.py @@ -117,7 +117,8 @@ def __on_error(self, _: QLocalSocket.LocalSocketError): self.timer.stop() self.__close() self.__game_finished(GameProcess.Code.ON_STARTUP) # 1234 is exit code for startup - logger.error(f"{self.game.app_name} ({self.game.app_title}): {self.socket.errorString()}") + else: + logger.error(f"{self.game.app_name} ({self.game.app_title}): {self.socket.errorString()}") def __game_finished(self, exit_code: int): self.finished.emit(exit_code) diff --git a/rare/shared/rare_core.py b/rare/shared/rare_core.py index 86799a6e1..d57c9dfa2 100644 --- a/rare/shared/rare_core.py +++ b/rare/shared/rare_core.py @@ -29,6 +29,7 @@ ) from .workers.uninstall import uninstall_game from .workers.worker import QueueWorkerInfo, QueueWorkerState +from .wrappers import Wrappers logger = getLogger("RareCore") @@ -52,6 +53,8 @@ def __init__(self, args: Namespace): 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() @@ -60,8 +63,8 @@ def __init__(self, args: Namespace): self.core(init=True) config_helper.init_config_handler(self.__core) self.image_manager(init=True) - - self.settings = QSettings() + self.__settings = QSettings() + self.__wrappers = Wrappers() self.queue_workers: List[QueueWorker] = [] self.queue_threadpool = QThreadPool() @@ -171,6 +174,12 @@ def image_manager(self, init: bool = False) -> ImageManager: 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 @@ -295,6 +304,9 @@ def __on_fetch_result(self, result: Tuple, result_type: int): if all([self.__fetched_games_dlcs, self.__fetched_entitlements]): logger.debug(f"Fetch time {time.perf_counter() - self.__start_time} seconds") + 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) diff --git a/rare/shared/workers/wine_resolver.py b/rare/shared/workers/wine_resolver.py index 891bd71aa..38b9bb131 100644 --- a/rare/shared/workers/wine_resolver.py +++ b/rare/shared/workers/wine_resolver.py @@ -3,50 +3,71 @@ 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, 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): + path = self._resolve_unix_path(self.command, self.environ, self.path) + self.signals.result_ready.emit(path, "default") + return - 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) + +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): - 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) + 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 @@ def __init__(self, core: LegendaryCore, games: Union[Iterable[RareGame], RareGam 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 @@ def run(self) -> None: 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 gettijng 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 + # lk: this is the original way of getting the path by parsing "system.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 @@ def run(self) -> None: 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 000000000..3d1469198 --- /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/utils/config_helper.py b/rare/utils/config_helper.py index 9a7cdbef4..66b7ac5ee 100644 --- a/rare/utils/config_helper.py +++ b/rare/utils/config_helper.py @@ -44,6 +44,14 @@ def remove_envvar(app_name: str, option: str) -> None: remove_option(f"{app_name}.env", option) +def get_option(app_name: str, option: str, fallback: Any = None) -> str: + return _config.get(app_name, option, fallback=fallback) + + +def get_envvar(app_name: str, option: str, fallback: Any = None) -> str: + return get_option(f"{app_name}.env", option, fallback=fallback) + + def remove_section(app_name: str) -> None: return # Disabled due to env variables implementation diff --git a/rare/utils/wine.py b/rare/utils/runners/__init__.py similarity index 57% rename from rare/utils/wine.py rename to rare/utils/runners/__init__.py index 9106884ff..bbc1b1e5a 100644 --- a/rare/utils/wine.py +++ b/rare/utils/runners/__init__.py @@ -1,41 +1,42 @@ 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(cmd: List, wine_env: Mapping) -> Tuple[str, str]: +def execute(command: List[str], environment: Mapping) -> Tuple[str, str]: if os.environ.get("container") == "flatpak": - flatpak_cmd = ["flatpak-spawn", "--host"] - for name, value in wine_env.items(): - flatpak_cmd.append(f"--env={name}={value}") - cmd = flatpak_cmd + cmd + flatpak = ["flatpak-spawn", "--host"] + for name, value in environment.items(): + flatpak.append(f"--env={name}={value}") + command = flatpak + command try: proc = subprocess.Popen( - cmd, + command, stdout=subprocess.PIPE, 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(cmd: 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 c0e3fb60f..edd870e97 100644 --- a/rare/utils/proton.py +++ b/rare/utils/runners/proton.py @@ -1,5 +1,7 @@ import os +import shlex from dataclasses import dataclass +from hashlib import md5 from logging import getLogger from typing import Optional, Union, List, Dict @@ -25,11 +27,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 @@ -37,23 +50,36 @@ def __eq__(self, other): 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"] @@ -61,33 +87,36 @@ def appid(self): 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]: @@ -184,16 +213,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"] = "" @@ -218,7 +249,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: @@ -233,6 +265,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 @@ -244,7 +278,9 @@ def find_tools() -> List[Union[ProtonTool, CompatibilityTool]]: 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 000000000..2b87332bf --- /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 a29b7dcfd..3e341cd74 100644 --- a/rare/utils/steam_grades.py +++ b/rare/utils/steam_grades.py @@ -39,8 +39,8 @@ def get_grade(steam_code): url = "https://www.protondb.com/api/v1/reports/summaries/" res = requests.get(f"{url}{steam_code}.json") try: - lista = orjson.loads(res.text) - except orjson.JSONDecodeError: + lista = orjson.loads(res.text) # pylint: disable=maybe-no-member + except orjson.JSONDecodeError: # pylint: disable=maybe-no-member return "fail" return lista["tier"] @@ -57,15 +57,15 @@ def load_json() -> dict: __active_download = True response = requests.get(url) __active_download = False - steam_ids = orjson.loads(response.text)["applist"]["apps"] + steam_ids = orjson.loads(response.text)["applist"]["apps"] # pylint: disable=maybe-no-member ids = {} for game in steam_ids: ids[game["name"]] = game["appid"] with open(file, "w") as f: - f.write(orjson.dumps(ids).decode("utf-8")) + f.write(orjson.dumps(ids).decode("utf-8")) # pylint: disable=maybe-no-member return ids else: - return orjson.loads(open(file, "r").read()) + return orjson.loads(open(file, "r").read()) # pylint: disable=maybe-no-member def get_steam_id(title: str) -> int: