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/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..1ecb3b2a3 100644 --- a/rare/components/tabs/settings/widgets/wrapper.py +++ b/rare/components/tabs/settings/widgets/wrapper.py @@ -1,7 +1,8 @@ -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.QtGui import QDrag, QDropEvent, QDragEnterEvent, QDragMoveEvent, QFont, QMouseEvent @@ -19,43 +20,34 @@ QMenu, ) +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 -} - - -class Wrapper: - pass +# extra_wrapper_regex = { +# "proton": "\".*proton\" run", # proton +# } 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 +64,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 +77,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 +122,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 +144,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 +169,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()) + @pyqtSlot(QWidget, int) + def __on_order_changed(self, widget: WrapperWidget, new_index: int): + wrapper = widget.data() + wrappers = self.wrappers.get_game_wrapper_list(self.app_name) + wrappers.remove(wrapper) + wrappers.insert(new_index, wrapper) + self.wrappers.set_game_wrapper_list(self.app_name, wrappers) - def get_wrapper_list(self): - wrappers = list(self.wrappers.values()) - wrappers.sort(key=lambda x: self.scroll_content.layout().indexOf(x)) - return [w.text for w in wrappers] - - def add_button_pressed(self): + @pyqtSlot() + def __on_add_button_pressed(self): dialog = QInputDialog(self) dialog.setWindowTitle(f"{self.tr('Add wrapper')} - {QCoreApplication.instance().applicationName()}") dialog.setLabelText(self.tr("Enter wrapper command")) accepted = dialog.exec() - wrapper = dialog.textValue() + command = dialog.textValue() dialog.deleteLater() if accepted: - self.add_wrapper(wrapper) - - def add_wrapper(self, text: str, position: int = -1, from_load: bool = False): - if text == "mangohud" and self.wrappers.get("mangohud"): - return - show_text = "" - for key, extra_wrapper in extra_wrapper_regex.items(): - if re.match(extra_wrapper, text): - show_text = key - if not show_text: - show_text = text.split()[0] - - # validate - if not text.strip(): # is empty - return - if not from_load: - if self.wrappers.get(text): - QMessageBox.warning( - self, self.tr("Warning"), self.tr("Wrapper {0} is already in the list").format(text) - ) - return - - if show_text != "proton" and not shutil.which(text.split()[0]): - if ( - QMessageBox.question( - self, - self.tr("Warning"), - self.tr("Wrapper {0} is not in $PATH. Add it anyway?").format(show_text), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - == QMessageBox.No - ): - return - - if text == "proton": - QMessageBox.warning( - self, - self.tr("Warning"), - self.tr("Do not insert proton manually. Add it through Proton settings"), - ) - return + wrapper = Wrapper(shlex.split(command)) + self.add_user_wrapper(wrapper) + def __add_wrapper(self, wrapper: Wrapper, position: int = -1): self.ui.widget_stack.setCurrentIndex(0) - - if widget := self.wrappers.get(show_text, None): - widget.deleteLater() - - widget = WrapperWidget(text, show_text, self.scroll_content) + widget = WrapperWidget(wrapper, self.wrapper_container) if position < 0: - self.scroll_content.layout().addWidget(widget) + self.wrapper_container.addWidget(widget) else: - self.scroll_content.layout().insertWidget(position, widget) + self.wrapper_container.insertWidget(position, widget) self.adjust_scrollarea( self.wrapper_scroll.horizontalScrollBar().minimum(), self.wrapper_scroll.horizontalScrollBar().maximum(), ) - widget.update_wrapper.connect(self.update_wrapper) - widget.delete_wrapper.connect(self.delete_wrapper) + widget.update_wrapper.connect(self.__update_wrapper) + widget.delete_wrapper.connect(self.__delete_wrapper) - self.wrappers[show_text] = widget - - 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/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/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..fcad4fcca --- /dev/null +++ b/rare/shared/wrappers.py @@ -0,0 +1,171 @@ +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: + commands = settings.value(f"{app_name}/wrapper", [], list) + settings.remove(f"{app_name}/wrapper") + if commands: + logger.info("Importing wrappers from Rare's config") + 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: