diff --git a/.gitmodules b/.gitmodules
index a78c14ab..e69de29b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,4 +0,0 @@
-[submodule "legendary"]
- path = rare/legendary
- url = https://github.com/dummerle/legendary
- branch = rare
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da4f8b49..7b8c56b7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,3 +23,13 @@ To contribute fork the repository and clone **your** repo: `git clone https://gi
and upload it to GitHub with `git commit -m "message"` and `git push`. Some IDEs like PyCharm can do this automatically.
If you uploaded your changes, create a pull request
+
+# Code Style Guidelines
+
+## Signals and threads
+
+## Function naming
+
+## UI Classes
+
+### Widget and Layout naming
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 4628bb83..1e6c7a5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,3 +34,13 @@ start = "rare.__main__:main"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
+
+[build-system]
+requires = ["setuptools>=42", "wheel", "nuitka", "toml"]
+build-backend = "nuitka.distutils.Build"
+
+[nuitka]
+show-scons = true
+enable-plugin = pyqt5,anti-bloat
+show-anti-bloat-changes = true
+nofollow-import-to = ["*.tests", "*.distutils"]
diff --git a/rare/__main__.py b/rare/__main__.py
index 79a1ef69..c510ed8d 100644
--- a/rare/__main__.py
+++ b/rare/__main__.py
@@ -13,8 +13,8 @@ def main():
multiprocessing.freeze_support()
# insert legendary for installed via pip/setup.py submodule to path
- if not __name__ == "__main__":
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), "legendary"))
+ # if not __name__ == "__main__":
+ # sys.path.insert(0, os.path.join(os.path.dirname(__file__), "legendary"))
# CLI Options
parser = ArgumentParser()
@@ -70,20 +70,18 @@ def main():
args = parser.parse_args()
- if args.desktop_shortcut:
- from rare.utils import utils
+ if args.desktop_shortcut or args.startmenu_shortcut:
+ from rare.utils.misc import create_desktop_link
+
+ if args.desktop_shortcut:
+ create_desktop_link(type_of_link="desktop", for_rare=True)
+
+ if args.startmenu_shortcut:
+ create_desktop_link(type_of_link="start_menu", for_rare=True)
- utils.create_desktop_link(type_of_link="desktop", for_rare=True)
print("Link created")
return
- if args.startmenu_shortcut:
- from rare.utils import utils
-
- utils.create_desktop_link(type_of_link="start_menu", for_rare=True)
- print("link created")
- return
-
if args.version:
from rare import __version__, code_name
@@ -123,10 +121,10 @@ def main():
if __name__ == "__main__":
# run from source
# insert raw legendary submodule
- sys.path.insert(
- 0, os.path.join(pathlib.Path(__file__).parent.absolute(), "legendary")
- )
+ # sys.path.insert(
+ # 0, os.path.join(pathlib.Path(__file__).parent.absolute(), "legendary")
+ # )
# insert source directory
- sys.path.insert(0, str(pathlib.Path(__file__).parents[1].absolute()))
+ #sys.path.insert(0, str(pathlib.Path(__file__).parents[1].absolute()))
main()
diff --git a/rare/app.py b/rare/app.py
index 9ef19f97..46be17fd 100644
--- a/rare/app.py
+++ b/rare/app.py
@@ -149,17 +149,25 @@ class App(RareApp):
def start_app(self):
for igame in self.core.get_installed_list():
if not os.path.exists(igame.install_path):
- legendary_utils.uninstall(igame.app_name, self.core)
+ # lk; since install_path is lost anyway, set keep_files to True
+ # lk: to avoid spamming the log with "file not found" errors
+ legendary_utils.uninstall_game(self.core, igame.app_name, keep_files=True)
logger.info(f"Uninstalled {igame.title}, because no game files exist")
continue
- if not os.path.exists(os.path.join(igame.install_path, igame.executable.replace("\\", "/").lstrip("/"))):
+ # lk: games that don't have an override and can't find their executable due to case sensitivity
+ # lk: will still erroneously require verification. This might need to be removed completely
+ # lk: or be decoupled from the verification requirement
+ if override_exe := self.core.lgd.config.get(igame.app_name, "override_exe", fallback=""):
+ igame_executable = override_exe
+ else:
+ igame_executable = igame.executable
+ if not os.path.exists(os.path.join(igame.install_path, igame_executable.replace("\\", "/").lstrip("/"))):
igame.needs_verification = True
self.core.lgd.set_installed_game(igame.app_name, igame)
logger.info(f"{igame.title} needs verification")
self.mainwindow = MainWindow()
- self.launch_dialog.close()
- self.tray_icon = TrayIcon(self)
+ self.tray_icon: TrayIcon = TrayIcon(self)
self.tray_icon.exit_action.triggered.connect(self.exit_app)
self.tray_icon.start_rare.triggered.connect(self.show_mainwindow)
self.tray_icon.activated.connect(
@@ -226,6 +234,7 @@ class App(RareApp):
self.mainwindow.hide()
threadpool = QThreadPool.globalInstance()
threadpool.waitForDone()
+ self.core.exit()
if self.mainwindow is not None:
self.mainwindow.close()
if self.tray_icon is not None:
diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py
index 20b00e3a..65501956 100644
--- a/rare/components/dialogs/install_dialog.py
+++ b/rare/components/dialogs/install_dialog.py
@@ -1,28 +1,32 @@
import os
-import platform
+import platform as pf
import sys
-from multiprocessing import Queue as MPQueue
-from typing import Tuple
+from typing import Tuple, List, Union, Optional
from PyQt5.QtCore import Qt, QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCloseEvent, QKeyEvent
from PyQt5.QtWidgets import QDialog, QFileDialog, QCheckBox
-from legendary.core import LegendaryCore
from legendary.models.downloading import ConditionCheckResult
from legendary.models.game import Game
-from legendary.utils.selective_dl import games
+from legendary.utils.selective_dl import get_sdl_appname
+from rare.lgndr.cli import LegendaryCLI
+from rare.lgndr.api_arguments import LgndrInstallGameArgs
+from rare.lgndr.api_exception import LgndrException
+from rare.lgndr.api_monkeys import LgndrIndirectStatus
+from rare.lgndr.core import LegendaryCore
from rare.shared import LegendaryCoreSingleton, ApiResultsSingleton, ArgumentsSingleton
from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog
from rare.utils.extra_widgets import PathEdit
-from rare.utils.models import InstallDownloadModel, InstallQueueItemModel
-from rare.utils.utils import get_size
+from rare.models.install import InstallDownloadModel, InstallQueueItemModel
+from rare.utils.misc import get_size
+from rare.utils import config_helper
class InstallDialog(QDialog, Ui_InstallDialog):
result_ready = pyqtSignal(InstallQueueItemModel)
- def __init__(self, dl_item: InstallQueueItemModel, update=False, silent=False, parent=None):
+ def __init__(self, dl_item: InstallQueueItemModel, update=False, repair=False, silent=False, parent=None):
super(InstallDialog, self).__init__(parent)
self.setupUi(self)
self.setAttribute(Qt.WA_DeleteOnClose, True)
@@ -31,7 +35,6 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.core = LegendaryCoreSingleton()
self.api_results = ApiResultsSingleton()
self.dl_item = dl_item
- self.dl_item.status_q = MPQueue()
self.app_name = self.dl_item.options.app_name
self.game = (
self.core.get_game(self.app_name)
@@ -42,6 +45,7 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.game_path = self.game.metadata.get("customAttributes", {}).get("FolderName", {}).get("value", "")
self.update = update
+ self.repair = repair
self.silent = silent
self.options_changed = False
@@ -84,18 +88,20 @@ class InstallDialog(QDialog, Ui_InstallDialog):
platforms.append("Mac")
self.platform_combo_box.addItems(platforms)
self.platform_combo_box.currentIndexChanged.connect(lambda: self.option_changed(None))
+ self.platform_combo_box.currentIndexChanged.connect(lambda: self.error_box())
self.platform_combo_box.currentIndexChanged.connect(
lambda i: self.error_box(
self.tr("Warning"),
- self.tr("You will not be able to run the Game if you choose {}").format(
+ self.tr("You will not be able to run the game if you select {} as platform").format(
self.platform_combo_box.itemText(i)
),
)
- if (self.platform_combo_box.currentText() == "Mac" and platform.system() != "Darwin")
+ if (self.platform_combo_box.currentText() == "Mac" and pf.system() != "Darwin")
else None
)
+ self.platform_combo_box.currentTextChanged.connect(self.setup_sdl_list)
- if platform.system() == "Darwin" and "Mac" in platforms:
+ if pf.system() == "Darwin" and "Mac" in platforms:
self.platform_combo_box.setCurrentIndex(platforms.index("Mac"))
self.max_workers_spin.setValue(self.core.lgd.config.getint("Legendary", "max_workers", fallback=0))
@@ -109,22 +115,10 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.ignore_space_check.stateChanged.connect(self.option_changed)
self.download_only_check.stateChanged.connect(lambda: self.non_reload_option_changed("download_only"))
self.shortcut_cb.stateChanged.connect(lambda: self.non_reload_option_changed("shortcut"))
- self.sdl_list_checks = list()
- try:
- for key, info in games[self.app_name].items():
- cb = QDataCheckBox(info["name"], info["tags"])
- if key == "__required":
- self.dl_item.options.sdl_list.extend(info["tags"])
- cb.setChecked(True)
- cb.setDisabled(True)
- self.sdl_list_layout.addWidget(cb)
- self.sdl_list_checks.append(cb)
- self.sdl_list_frame.resize(self.sdl_list_frame.minimumSize())
- for cb in self.sdl_list_checks:
- cb.stateChanged.connect(self.option_changed)
- except (KeyError, AttributeError):
- self.sdl_list_frame.setVisible(False)
- self.sdl_list_label.setVisible(False)
+
+ self.sdl_list_cbs: List[TagCheckBox] = []
+ self.config_tags: Optional[List[str]] = None
+ self.setup_sdl_list("Mac" if pf.system() == "Darwin" and "Mac" in platforms else "Windows")
self.install_button.setEnabled(False)
@@ -138,7 +132,7 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.shortcut_cb.setVisible(False)
self.shortcut_lbl.setVisible(False)
- if platform.system() == "Darwin":
+ if pf.system() == "Darwin":
self.shortcut_cb.setDisabled(True)
self.shortcut_cb.setChecked(False)
self.shortcut_cb.setToolTip(self.tr("Creating a shortcut is not supported on MacOS"))
@@ -153,7 +147,7 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.verify_button.clicked.connect(self.verify_clicked)
self.install_button.clicked.connect(self.install_clicked)
- self.resize(self.minimumSize())
+ self.install_dialog_layout.setSizeConstraint(self.install_dialog_layout.SetFixedSize)
def execute(self):
if self.silent:
@@ -163,21 +157,54 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.verify_clicked()
self.show()
+ @pyqtSlot(str)
+ def setup_sdl_list(self, platform="Windows"):
+ for cb in self.sdl_list_cbs:
+ cb.disconnect()
+ cb.deleteLater()
+ self.sdl_list_cbs.clear()
+
+ if config_tags := self.core.lgd.config.get(self.game.app_name, 'install_tags', fallback=None):
+ self.config_tags = config_tags.split(",")
+ config_disable_sdl = self.core.lgd.config.getboolean(self.game.app_name, 'disable_sdl', fallback=False)
+ sdl_name = get_sdl_appname(self.game.app_name)
+ if not config_disable_sdl and sdl_name is not None:
+ # FIXME: this should be updated whenever platform changes
+ sdl_data = self.core.get_sdl_data(sdl_name, platform=platform)
+ if sdl_data:
+ for tag, info in sdl_data.items():
+ cb = TagCheckBox(info["name"], info["tags"])
+ if tag == "__required":
+ cb.setChecked(True)
+ cb.setDisabled(True)
+ if self.config_tags is not None:
+ if all(elem in self.config_tags for elem in info["tags"]):
+ cb.setChecked(True)
+ self.sdl_list_layout.addWidget(cb)
+ self.sdl_list_cbs.append(cb)
+ self.sdl_list_frame.resize(self.sdl_list_frame.minimumSize())
+ for cb in self.sdl_list_cbs:
+ cb.stateChanged.connect(self.option_changed)
+ else:
+ self.sdl_list_frame.setVisible(False)
+ self.sdl_list_label.setVisible(False)
+
def get_options(self):
self.dl_item.options.base_path = self.install_dir_edit.text() if not self.update else None
self.dl_item.options.max_workers = self.max_workers_spin.value()
- self.dl_item.options.max_shm = self.max_memory_spin.value()
- self.dl_item.options.dl_optimizations = self.dl_optimizations_check.isChecked()
+ self.dl_item.options.shared_memory = self.max_memory_spin.value()
+ self.dl_item.options.order_opt = self.dl_optimizations_check.isChecked()
self.dl_item.options.force = self.force_download_check.isChecked()
- self.dl_item.options.ignore_space_req = self.ignore_space_check.isChecked()
+ self.dl_item.options.ignore_space = self.ignore_space_check.isChecked()
self.dl_item.options.no_install = self.download_only_check.isChecked()
self.dl_item.options.platform = self.platform_combo_box.currentText()
- self.dl_item.options.sdl_list = [""]
- for cb in self.sdl_list_checks:
- if data := cb.isChecked():
- # noinspection PyTypeChecker
- self.dl_item.options.sdl_list.extend(data)
+ if self.sdl_list_cbs:
+ self.dl_item.options.install_tag = [""]
+ for cb in self.sdl_list_cbs:
+ if data := cb.isChecked():
+ # noinspection PyTypeChecker
+ self.dl_item.options.install_tag.extend(data)
def get_download_info(self):
self.dl_item.download = None
@@ -218,6 +245,12 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.dl_item.options.install_preqs = self.install_preqs_check.isChecked()
def cancel_clicked(self):
+ if self.config_tags:
+ config_helper.add_option(self.game.app_name, 'install_tags', ','.join(self.config_tags))
+ else:
+ # lk: this is purely for cleaning any install tags we might have added erroneously to the config
+ config_helper.remove_option(self.game.app_name, 'install_tags')
+
self.dl_item.download = None
self.reject_close = False
self.close()
@@ -243,7 +276,7 @@ class InstallDialog(QDialog, Ui_InstallDialog):
self.cancel_button.setEnabled(True)
if self.silent:
self.close()
- if platform.system() == "Windows" or ArgumentsSingleton().debug:
+ if pf.system() == "Windows" or ArgumentsSingleton().debug:
if dl_item.igame.prereq_info and not dl_item.igame.prereq_info.get("installed", False):
self.install_preqs_check.setVisible(True)
self.install_preqs_lbl.setVisible(True)
@@ -299,59 +332,34 @@ class InstallInfoWorker(QRunnable):
self.signals = InstallInfoWorker.Signals()
self.core = core
self.dl_item = dl_item
- self.is_overlay_install = self.dl_item.options.overlay
self.game = game
@pyqtSlot()
def run(self):
try:
- if not self.is_overlay_install:
- download = InstallDownloadModel(
- *self.core.prepare_download(
- app_name=self.dl_item.options.app_name,
- base_path=self.dl_item.options.base_path,
- force=self.dl_item.options.force,
- no_install=self.dl_item.options.no_install,
- status_q=self.dl_item.status_q,
- max_shm=self.dl_item.options.max_shm,
- max_workers=self.dl_item.options.max_workers,
- # game_folder=,
- # disable_patching=,
- # override_manifest=,
- # override_old_manifest=,
- # override_base_url=,
- platform=self.dl_item.options.platform,
- # file_prefix_filter=,
- # file_exclude_filter=,
- # file_install_tag=,
- dl_optimizations=self.dl_item.options.dl_optimizations,
- # dl_timeout=,
- repair=self.dl_item.options.repair,
- # repair_use_latest=,
- ignore_space_req=self.dl_item.options.ignore_space_req,
- # disable_delta=,
- # override_delta_manifest=,
- # reset_sdl=,
- sdl_prompt=lambda app_name, title: self.dl_item.options.sdl_list,
- )
+ if not self.dl_item.options.overlay:
+ cli = LegendaryCLI(self.core)
+ status = LgndrIndirectStatus()
+ result = cli.install_game(
+ LgndrInstallGameArgs(**self.dl_item.options.as_install_kwargs(), indirect_status=status)
)
-
+ if result:
+ download = InstallDownloadModel(*result)
+ else:
+ raise LgndrException(status.message)
else:
if not os.path.exists(path := self.dl_item.options.base_path):
os.makedirs(path)
dlm, analysis, igame = self.core.prepare_overlay_install(
- path=self.dl_item.options.base_path,
- status_queue=self.dl_item.status_q,
- max_workers=self.dl_item.options.max_workers,
- force=self.dl_item.options.force,
+ path=self.dl_item.options.base_path
)
download = InstallDownloadModel(
- dlmanager=dlm,
+ dlm=dlm,
analysis=analysis,
- game=self.game,
igame=igame,
+ game=self.game,
repair=False,
repair_file="",
res=ConditionCheckResult(), # empty
@@ -361,19 +369,21 @@ class InstallInfoWorker(QRunnable):
self.signals.result.emit(download)
else:
self.signals.failed.emit("\n".join(str(i) for i in download.res.failures))
+ except LgndrException as ret:
+ self.signals.failed.emit(ret.message)
except Exception as e:
self.signals.failed.emit(str(e))
self.signals.finished.emit()
-class QDataCheckBox(QCheckBox):
- def __init__(self, text, data=None, parent=None):
- super(QDataCheckBox, self).__init__(parent)
+class TagCheckBox(QCheckBox):
+ def __init__(self, text, tags: List[str], parent=None):
+ super(TagCheckBox, self).__init__(parent)
self.setText(text)
- self.data = data
+ self.tags = tags
- def isChecked(self):
- if super(QDataCheckBox, self).isChecked():
- return self.data
+ def isChecked(self) -> Union[bool, List[str]]:
+ if super(TagCheckBox, self).isChecked():
+ return self.tags
else:
return False
diff --git a/rare/components/dialogs/launch_dialog.py b/rare/components/dialogs/launch_dialog.py
index 14696b8f..4a1de3e8 100644
--- a/rare/components/dialogs/launch_dialog.py
+++ b/rare/components/dialogs/launch_dialog.py
@@ -10,7 +10,7 @@ from rare.models.apiresults import ApiResults
from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton, ApiResultsSingleton
from rare.shared.image_manager import ImageManagerSingleton
from rare.ui.components.dialogs.launch_dialog import Ui_LaunchDialog
-from rare.utils.utils import CloudWorker
+from rare.utils.misc import CloudWorker
logger = getLogger("Login")
diff --git a/rare/components/dialogs/login/__init__.py b/rare/components/dialogs/login/__init__.py
index ab5fe050..2357adcf 100644
--- a/rare/components/dialogs/login/__init__.py
+++ b/rare/components/dialogs/login/__init__.py
@@ -117,8 +117,9 @@ class LoginDialog(QDialog):
self.close()
else:
raise ValueError("Login failed.")
- except ValueError as e:
+ except Exception as e:
logger.error(str(e))
+ self.core.lgd.invalidate_userdata()
self.ui.next_button.setEnabled(False)
self.logged_in = False
- QMessageBox.warning(self, "Error", str(e))
+ QMessageBox.warning(None, self.tr("Login error"), str(e))
diff --git a/rare/components/dialogs/login/browser_login.py b/rare/components/dialogs/login/browser_login.py
index bb497aee..49c1eb66 100644
--- a/rare/components/dialogs/login/browser_login.py
+++ b/rare/components/dialogs/login/browser_login.py
@@ -10,7 +10,7 @@ from legendary.utils import webview_login
from rare.ui.components.dialogs.login.browser_login import Ui_BrowserLogin
from rare.utils.extra_widgets import IndicatorLineEdit
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = getLogger("BrowserLogin")
diff --git a/rare/components/dialogs/uninstall_dialog.py b/rare/components/dialogs/uninstall_dialog.py
index 13caab00..0d4fb88b 100644
--- a/rare/components/dialogs/uninstall_dialog.py
+++ b/rare/components/dialogs/uninstall_dialog.py
@@ -1,36 +1,38 @@
+from typing import Tuple
+
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QDialog,
QLabel,
QVBoxLayout,
QCheckBox,
- QFormLayout,
QHBoxLayout,
QPushButton,
)
from legendary.models.game import Game
-from rare.utils.utils import icon
+from rare.utils.misc import icon
class UninstallDialog(QDialog):
def __init__(self, game: Game):
super(UninstallDialog, self).__init__()
- self.setWindowTitle("Uninstall Game")
- self.info = 0
self.setAttribute(Qt.WA_DeleteOnClose, True)
- self.layout = QVBoxLayout()
+ self.setWindowTitle("Uninstall Game")
+ layout = QVBoxLayout()
self.info_text = QLabel(
- self.tr("Do you really want to uninstall {}").format(game.app_title)
+ self.tr("Do you really want to uninstall {} ?").format(game.app_title)
)
- self.layout.addWidget(self.info_text)
- self.keep_files = QCheckBox(self.tr("Keep Files"))
- self.form = QFormLayout()
- self.form.setContentsMargins(0, 10, 0, 10)
- self.form.addRow(QLabel(self.tr("Do you want to keep files?")), self.keep_files)
- self.layout.addLayout(self.form)
+ layout.addWidget(self.info_text)
+ self.keep_files = QCheckBox(self.tr("Keep game files?"))
+ self.keep_config = QCheckBox(self.tr("Keep game configuation?"))
+ form_layout = QVBoxLayout()
+ form_layout.setContentsMargins(6, 6, 0, 6)
+ form_layout.addWidget(self.keep_files)
+ form_layout.addWidget(self.keep_config)
+ layout.addLayout(form_layout)
- self.button_layout = QHBoxLayout()
+ button_layout = QHBoxLayout()
self.ok_button = QPushButton(
icon("ei.remove-circle", color="red"), self.tr("Uninstall")
)
@@ -39,20 +41,22 @@ class UninstallDialog(QDialog):
self.cancel_button = QPushButton(self.tr("Cancel"))
self.cancel_button.clicked.connect(self.cancel)
- self.button_layout.addStretch(1)
- self.button_layout.addWidget(self.ok_button)
- self.button_layout.addWidget(self.cancel_button)
- self.layout.addLayout(self.button_layout)
- self.setLayout(self.layout)
+ button_layout.addWidget(self.ok_button)
+ button_layout.addStretch(1)
+ button_layout.addWidget(self.cancel_button)
+ layout.addLayout(button_layout)
+ self.setLayout(layout)
- def get_information(self):
+ self.options: Tuple[bool, bool, bool] = (False, False, False)
+
+ def get_options(self) -> Tuple[bool, bool, bool]:
self.exec_()
- return self.info
+ return self.options
def ok(self):
- self.info = {"keep_files": self.keep_files.isChecked()}
+ self.options = (True, self.keep_files.isChecked(), self.keep_config.isChecked())
self.close()
def cancel(self):
- self.info = 0
+ self.options = (False, False, False)
self.close()
diff --git a/rare/components/tabs/__init__.py b/rare/components/tabs/__init__.py
index bb213582..a8e62e82 100644
--- a/rare/components/tabs/__init__.py
+++ b/rare/components/tabs/__init__.py
@@ -2,14 +2,14 @@ from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QMenu, QTabWidget, QWidget, QWidgetAction, QShortcut
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
-from rare.components.tabs.account import MiniWidget
+from rare.components.tabs.account import AccountWidget
from rare.components.tabs.downloads import DownloadsTab
from rare.components.tabs.games import GamesTab
from rare.components.tabs.settings import SettingsTab
from rare.components.tabs.settings.debug import DebugSettings
from rare.components.tabs.shop import Shop
from rare.components.tabs.tab_utils import MainTabBar, TabButtonWidget
-from rare.utils.utils import icon
+from rare.utils.misc import icon
class TabWidget(QTabWidget):
@@ -54,9 +54,9 @@ class TabWidget(QTabWidget):
self.addTab(self.account, "")
self.setTabEnabled(disabled_tab + 1, False)
- self.mini_widget = MiniWidget()
+ self.account_widget = AccountWidget()
account_action = QWidgetAction(self)
- account_action.setDefaultWidget(self.mini_widget)
+ account_action.setDefaultWidget(self.account_widget)
account_button = TabButtonWidget("mdi.account-circle", "Account", fallback_icon="fa.user")
account_button.setMenu(QMenu())
account_button.menu().addAction(account_action)
diff --git a/rare/components/tabs/account/__init__.py b/rare/components/tabs/account/__init__.py
index 8b327b73..2f522b67 100644
--- a/rare/components/tabs/account/__init__.py
+++ b/rare/components/tabs/account/__init__.py
@@ -3,34 +3,33 @@ import webbrowser
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMessageBox, QLabel, QPushButton
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
-from rare.utils.utils import icon
+from rare.utils.misc import icon
-class MiniWidget(QWidget):
+class AccountWidget(QWidget):
def __init__(self):
- super(MiniWidget, self).__init__()
- self.layout = QVBoxLayout()
+ super(AccountWidget, self).__init__()
self.core = LegendaryCoreSingleton()
self.signals = GlobalSignalsSingleton()
- self.layout.addWidget(QLabel("Account"))
+
username = self.core.lgd.userdata.get("display_name")
if not username:
username = "Offline"
- self.layout.addWidget(QLabel(self.tr("Logged in as {}").format(username)))
-
self.open_browser = QPushButton(icon("fa.external-link"), self.tr("Account settings"))
self.open_browser.clicked.connect(
lambda: webbrowser.open(
"https://www.epicgames.com/account/personal?productName=epicgames"
)
)
- self.layout.addWidget(self.open_browser)
-
self.logout_button = QPushButton(self.tr("Logout"))
self.logout_button.clicked.connect(self.logout)
- self.layout.addWidget(self.logout_button)
- self.setLayout(self.layout)
+
+ layout = QVBoxLayout(self)
+ layout.addWidget(QLabel(self.tr("Account")))
+ layout.addWidget(QLabel(self.tr("Logged in as {}").format(username)))
+ layout.addWidget(self.open_browser)
+ layout.addWidget(self.logout_button)
def logout(self):
reply = QMessageBox.question(
diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py
index 17225e39..26739a26 100644
--- a/rare/components/tabs/downloads/__init__.py
+++ b/rare/components/tabs/downloads/__init__.py
@@ -1,8 +1,8 @@
import datetime
from logging import getLogger
-from typing import List, Dict
+from typing import List, Dict, Union
-from PyQt5.QtCore import QThread, pyqtSignal, QSettings
+from PyQt5.QtCore import QThread, pyqtSignal, QSettings, pyqtSlot
from PyQt5.QtWidgets import (
QWidget,
QMessageBox,
@@ -11,17 +11,17 @@ from PyQt5.QtWidgets import (
QPushButton,
QGroupBox,
)
-
from legendary.core import LegendaryCore
-from legendary.models.downloading import UIUpdate
from legendary.models.game import Game, InstalledGame
-from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
+
from rare.components.dialogs.install_dialog import InstallDialog
from rare.components.tabs.downloads.dl_queue_widget import DlQueueWidget, DlWidget
from rare.components.tabs.downloads.download_thread import DownloadThread
+from rare.lgndr.downloading import UIUpdate
+from rare.models.install import InstallOptionsModel, InstallQueueItemModel
+from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.ui.components.tabs.downloads.downloads_tab import Ui_DownloadsTab
-from rare.utils.models import InstallOptionsModel, InstallQueueItemModel
-from rare.utils.utils import get_size
+from rare.utils.misc import get_size, create_desktop_link
logger = getLogger("Download")
@@ -56,8 +56,8 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.update_layout.addWidget(self.update_text)
self.update_text.setVisible(len(updates) == 0)
- for name in updates:
- self.add_update(self.core.get_installed_game(name))
+ for app_name in updates:
+ self.add_update(app_name)
self.queue_widget.item_removed.connect(self.queue_item_removed)
@@ -66,7 +66,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.signals.game_uninstalled.connect(self.remove_update)
self.signals.add_download.connect(
- lambda app_name: self.add_update(self.core.get_installed_game(app_name))
+ lambda app_name: self.add_update(app_name)
)
self.signals.game_uninstalled.connect(self.game_uninstalled)
@@ -77,14 +77,17 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
w.update_button.setDisabled(False)
w.update_with_settings.setDisabled(False)
- def add_update(self, igame: InstalledGame):
- widget = UpdateWidget(self.core, igame, self)
+ def add_update(self, app_name: str):
+ if old_widget := self.update_widgets.get(app_name, False):
+ old_widget.deleteLater()
+ self.update_widgets.pop(app_name)
+ widget = UpdateWidget(self.core, app_name, self)
self.update_layout.addWidget(widget)
- self.update_widgets[igame.app_name] = widget
+ self.update_widgets[app_name] = widget
widget.update_signal.connect(self.get_install_options)
if QSettings().value("auto_update", False, bool):
self.get_install_options(
- InstallOptionsModel(app_name=igame.app_name, update=True, silent=True)
+ InstallOptionsModel(app_name=app_name, update=True, silent=True)
)
widget.update_button.setDisabled(True)
self.update_text.setVisible(False)
@@ -97,14 +100,14 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.queue_widget.update_queue(self.dl_queue)
break
- # game has available update
- if app_name in self.update_widgets.keys():
- self.remove_update(app_name)
-
# if game is updating
if self.active_game and self.active_game.app_name == app_name:
self.stop_download()
+ # game has available update
+ if app_name in self.update_widgets.keys():
+ self.remove_update(app_name)
+
def remove_update(self, app_name):
if w := self.update_widgets.get(app_name):
w.deleteLater()
@@ -120,6 +123,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
def stop_download(self):
self.thread.kill()
+ self.kill_button.setEnabled(False)
def install_game(self, queue_item: InstallQueueItemModel):
if self.active_game is None:
@@ -134,8 +138,8 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.queue_widget.update_queue(self.dl_queue)
self.active_game = queue_item.download.game
self.thread = DownloadThread(self.core, queue_item)
- self.thread.status.connect(self.status)
- self.thread.statistics.connect(self.statistics)
+ self.thread.ret_status.connect(self.status)
+ self.thread.ui_update.connect(self.progress_update)
self.thread.start()
self.kill_button.setDisabled(False)
self.analysis = queue_item.download.analysis
@@ -143,8 +147,16 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.signals.installation_started.emit(self.active_game.app_name)
- def status(self, text):
- if text == "finish":
+ @pyqtSlot(DownloadThread.ReturnStatus)
+ def status(self, result: DownloadThread.ReturnStatus):
+ if result.ret_code == result.ReturnCode.FINISHED:
+ if result.shortcuts:
+ if not create_desktop_link(result.app_name, self.core, "desktop"):
+ # maybe add it to download summary, to show in finished downloads
+ pass
+ else:
+ logger.info("Desktop shortcut written")
+
self.dl_name.setText(self.tr("Download finished. Reload library"))
logger.info(f"Download finished: {self.active_game.app_title}")
@@ -179,10 +191,10 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
else:
self.queue_widget.update_queue(self.dl_queue)
- elif text[:5] == "error":
- QMessageBox.warning(self, "warn", f"Download error: {text[6:]}")
+ elif result.ret_code == result.ReturnCode.ERROR:
+ QMessageBox.warning(self, self.tr("Error"), f"Download error: {result.message}")
- elif text == "stop":
+ elif result.ret_code == result.ReturnCode.STOPPED:
self.reset_infos()
if w := self.update_widgets.get(self.active_game.app_name):
w.update_button.setDisabled(False)
@@ -202,7 +214,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
self.downloaded.setText("n/a")
self.analysis = None
- def statistics(self, ui_update: UIUpdate):
+ def progress_update(self, ui_update: UIUpdate):
self.progress_bar.setValue(
100 * ui_update.total_downloaded // self.analysis.dl_size
)
@@ -218,12 +230,16 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
100 * ui_update.total_downloaded // self.analysis.dl_size
)
- def get_time(self, seconds: int) -> str:
+ def get_time(self, seconds: Union[int, float]) -> str:
return str(datetime.timedelta(seconds=seconds))
def on_install_dialog_closed(self, download_item: InstallQueueItemModel):
if download_item:
self.install_game(download_item)
+ # lk: In case the download in comming from game verification/repair
+ if w := self.update_widgets.get(download_item.options.app_name):
+ w.update_button.setDisabled(True)
+ w.update_with_settings.setDisabled(True)
self.signals.set_main_tab_index.emit(1)
else:
if w := self.update_widgets.get(download_item.options.app_name):
@@ -241,19 +257,6 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
install_dialog.result_ready.connect(self.on_install_dialog_closed)
install_dialog.execute()
- def start_download(self, download_item: InstallQueueItemModel):
- downloads = (
- len(self.downloadTab.dl_queue)
- + len(self.downloadTab.update_widgets.keys())
- + 1
- )
- self.setTabText(
- 1, "Downloads" + ((" (" + str(downloads) + ")") if downloads != 0 else "")
- )
- self.setCurrentIndex(1)
- self.downloadTab.install_game(download_item)
- self.games_tab.start_download(download_item.options.app_name)
-
@property
def is_download_active(self):
return self.active_game is not None
@@ -262,37 +265,37 @@ class DownloadsTab(QWidget, Ui_DownloadsTab):
class UpdateWidget(QWidget):
update_signal = pyqtSignal(InstallOptionsModel)
- def __init__(self, core: LegendaryCore, igame: InstalledGame, parent):
+ def __init__(self, core: LegendaryCore, app_name: str, parent):
super(UpdateWidget, self).__init__(parent=parent)
self.core = core
- self.game = igame
+ self.game: Game = core.get_game(app_name)
+ self.igame: InstalledGame = self.core.get_installed_game(app_name)
- self.layout = QVBoxLayout()
- self.title = QLabel(self.game.title)
- self.layout.addWidget(self.title)
+ layout = QVBoxLayout()
+ self.title = QLabel(self.igame.title)
+ layout.addWidget(self.title)
self.update_button = QPushButton(self.tr("Update Game"))
self.update_button.clicked.connect(lambda: self.update_game(True))
self.update_with_settings = QPushButton("Update with settings")
self.update_with_settings.clicked.connect(lambda: self.update_game(False))
- self.layout.addWidget(self.update_button)
- self.layout.addWidget(self.update_with_settings)
- self.layout.addWidget(
+ layout.addWidget(self.update_button)
+ layout.addWidget(self.update_with_settings)
+ layout.addWidget(
QLabel(
- self.tr("Version: ")
- + self.game.version
- + " -> "
- + self.core.get_asset(
- self.game.app_name, self.game.platform, False
- ).build_version
+ self.tr("Version: ")
+ + self.igame.version
+ + " >> "
+ + self.game.app_version(self.igame.platform)
+ + ""
)
)
- self.setLayout(self.layout)
+ self.setLayout(layout)
def update_game(self, auto: bool):
self.update_button.setDisabled(True)
self.update_with_settings.setDisabled(True)
self.update_signal.emit(
- InstallOptionsModel(app_name=self.game.app_name, silent=auto)
+ InstallOptionsModel(app_name=self.igame.app_name, silent=auto)
) # True if settings
diff --git a/rare/components/tabs/downloads/dl_queue_widget.py b/rare/components/tabs/downloads/dl_queue_widget.py
index 2b0455c1..18152273 100644
--- a/rare/components/tabs/downloads/dl_queue_widget.py
+++ b/rare/components/tabs/downloads/dl_queue_widget.py
@@ -10,8 +10,8 @@ from PyQt5.QtWidgets import (
QWidget,
)
-from rare.utils.models import InstallQueueItemModel
-from rare.utils.utils import icon
+from rare.models.install import InstallQueueItemModel
+from rare.utils.misc import icon
logger = getLogger("QueueWidget")
diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py
index 914e3f1a..5f672558 100644
--- a/rare/components/tabs/downloads/download_thread.py
+++ b/rare/components/tabs/downloads/download_thread.py
@@ -1,209 +1,150 @@
import os
import platform
import queue
-import sys
import time
+from dataclasses import dataclass
+from enum import IntEnum
from logging import getLogger
-from queue import Empty
+from typing import List, Optional, Dict
-import psutil
from PyQt5.QtCore import QThread, pyqtSignal, QProcess
from legendary.core import LegendaryCore
-from legendary.models.downloading import UIUpdate, WriterTask
-from rare.shared import GlobalSignalsSingleton
-from rare.utils.models import InstallQueueItemModel
-from rare.utils.utils import create_desktop_link
+from rare.lgndr.api_monkeys import DLManagerSignals
+from rare.lgndr.cli import LegendaryCLI
+from rare.lgndr.downloading import UIUpdate
+from rare.models.install import InstallQueueItemModel
+from rare.shared import GlobalSignalsSingleton, ArgumentsSingleton
logger = getLogger("DownloadThread")
class DownloadThread(QThread):
- status = pyqtSignal(str)
- statistics = pyqtSignal(UIUpdate)
+ @dataclass
+ class ReturnStatus:
+ class ReturnCode(IntEnum):
+ ERROR = 1
+ STOPPED = 2
+ FINISHED = 3
- def __init__(self, core: LegendaryCore, queue_item: InstallQueueItemModel):
+ app_name: str
+ ret_code: ReturnCode = ReturnCode.ERROR
+ message: str = ""
+ dlcs: Optional[List[Dict]] = None
+ sync_saves: bool = False
+ tip_url: str = ""
+ shortcuts: bool = False
+
+ ret_status = pyqtSignal(ReturnStatus)
+ ui_update = pyqtSignal(UIUpdate)
+
+ def __init__(self, core: LegendaryCore, item: InstallQueueItemModel):
super(DownloadThread, self).__init__()
- self.core = core
self.signals = GlobalSignalsSingleton()
- self.dlm = queue_item.download.dlmanager
- self.no_install = queue_item.options.no_install
- self.status_q = queue_item.status_q
- self.igame = queue_item.download.igame
- self.repair = queue_item.download.repair
- self.repair_file = queue_item.download.repair_file
- self.queue_item = queue_item
-
- self._kill = False
+ self.core: LegendaryCore = core
+ self.item: InstallQueueItemModel = item
+ self.dlm_signals: DLManagerSignals = DLManagerSignals()
def run(self):
- start_time = time.time()
- dl_stopped = False
+ cli = LegendaryCLI(self.core)
+ self.item.download.dlm.logging_queue = cli.logging_queue
+ self.item.download.dlm.proc_debug = ArgumentsSingleton().debug
+ ret = DownloadThread.ReturnStatus(self.item.download.game.app_name)
+ start_t = time.time()
try:
-
- self.dlm.start()
+ self.item.download.dlm.start()
time.sleep(1)
- while self.dlm.is_alive():
- if self._kill:
- self.status.emit("stop")
- logger.info("Download stopping...")
-
- # The code below is a temporary solution.
- # It should be removed once legendary supports stopping downloads more gracefully.
-
- self.dlm.running = False
-
- # send conditions to unlock threads if they aren't already
- for cond in self.dlm.conditions:
- with cond:
- cond.notify()
-
- # make sure threads are dead.
- for t in self.dlm.threads:
- t.join(timeout=5.0)
- if t.is_alive():
- logger.warning(f"Thread did not terminate! {repr(t)}")
-
- # clean up all the queues, otherwise this process won't terminate properly
- for name, q in zip(
- (
- "Download jobs",
- "Writer jobs",
- "Download results",
- "Writer results",
- ),
- (
- self.dlm.dl_worker_queue,
- self.dlm.writer_queue,
- self.dlm.dl_result_q,
- self.dlm.writer_result_q,
- ),
- ):
- logger.debug(f'Cleaning up queue "{name}"')
- try:
- while True:
- _ = q.get_nowait()
- except Empty:
- q.close()
- q.join_thread()
- except AttributeError:
- logger.warning(f"Queue {name} did not close")
-
- if self.dlm.writer_queue:
- # cancel installation
- self.dlm.writer_queue.put_nowait(WriterTask("", kill=True))
-
- # forcibly kill DL workers that are not actually dead yet
- for child in self.dlm.children:
- if child.exitcode is None:
- child.terminate()
-
- if self.dlm.shared_memory:
- # close up shared memory
- self.dlm.shared_memory.close()
- self.dlm.shared_memory.unlink()
- self.dlm.shared_memory = None
-
- self.dlm.kill()
-
- # force kill any threads that are somehow still alive
- for proc in psutil.process_iter():
- # check whether the process name matches
- if (
- sys.platform in ["linux", "darwin"]
- and proc.name() == "DownloadThread"
- ):
- proc.kill()
- elif (
- sys.platform == "win32"
- and proc.name() == "python.exe"
- and proc.create_time() >= start_time
- ):
- proc.kill()
-
- logger.info("Download stopped. It can be continued later.")
- dl_stopped = True
+ while self.item.download.dlm.is_alive():
try:
- if not dl_stopped:
- self.statistics.emit(self.status_q.get(timeout=1))
+ self.ui_update.emit(self.item.download.dlm.status_queue.get(timeout=1.0))
except queue.Empty:
pass
-
- self.dlm.join()
-
+ if self.dlm_signals.update:
+ try:
+ self.item.download.dlm.signals_queue.put(self.dlm_signals, block=False, timeout=1.0)
+ except queue.Full:
+ pass
+ time.sleep(self.item.download.dlm.update_interval / 10)
+ self.item.download.dlm.join()
except Exception as e:
- logger.error(
- f"Installation failed after {time.time() - start_time:.02f} seconds: {e}"
- )
- self.status.emit(f"error {e}")
- return
-
- else:
- if dl_stopped:
- return
- self.status.emit("dl_finished")
end_t = time.time()
- logger.info(f"Download finished in {end_t - start_time}s")
- game = self.core.get_game(self.igame.app_name)
+ logger.error(f"Installation failed after {end_t - start_t:.02f} seconds.")
+ logger.warning(f"The following exception occurred while waiting for the downloader to finish: {e!r}.")
+ ret.ret_code = ret.ReturnCode.ERROR
+ ret.message = f"{e!r}"
+ self.ret_status.emit(ret)
+ return
+ else:
+ end_t = time.time()
+ if self.dlm_signals.kill is True:
+ logger.info(f"Download stopped after {end_t - start_t:.02f} seconds.")
+ ret.ret_code = ret.ReturnCode.STOPPED
+ self.ret_status.emit(ret)
+ return
+ logger.info(f"Download finished in {end_t - start_t:.02f} seconds.")
- if self.queue_item.options.overlay:
+ ret.ret_code = ret.ReturnCode.FINISHED
+
+ if self.item.options.overlay:
self.signals.overlay_installation_finished.emit()
- self.core.finish_overlay_install(self.igame)
- self.status.emit("finish")
+ self.core.finish_overlay_install(self.item.download.igame)
+ self.ret_status.emit(ret)
return
- if not self.no_install:
- postinstall = self.core.install_game(self.igame)
+ if not self.item.options.no_install:
+ postinstall = self.core.install_game(self.item.download.igame)
if postinstall:
- self._handle_postinstall(postinstall, self.igame)
+ # LegendaryCLI(self.core)._handle_postinstall(
+ # postinstall,
+ # self.item.download.igame,
+ # False,
+ # self.item.options.install_preqs,
+ # )
+ self._handle_postinstall(postinstall, self.item.download.igame)
- dlcs = self.core.get_dlc_for_game(self.igame.app_name)
- if dlcs:
- print("The following DLCs are available for this game:")
+ dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name)
+ if dlcs and not self.item.options.skip_dlcs:
+ ret.dlcs = []
for dlc in dlcs:
- print(
- f" - {dlc.app_title} (App name: {dlc.app_name}, version: {dlc.app_version})"
+ ret.dlcs.append(
+ {
+ "app_name": dlc.app_name,
+ "app_title": dlc.app_title,
+ "app_version": dlc.app_version(self.item.options.platform),
+ }
)
- print(
- "Manually installing DLCs works the same; just use the DLC app name instead."
- )
- # install_dlcs = QMessageBox.question(self, "", "Do you want to install the prequisites", QMessageBox.Yes|QMessageBox.No) == QMessageBox.Yes
- # TODO
- if game.supports_cloud_saves and not game.is_dlc:
- logger.info(
- 'This game supports cloud saves, syncing is handled by the "sync-saves" command.'
- )
- logger.info(
- f'To download saves for this game run "legendary sync-saves {game.app_name}"'
- )
- old_igame = self.core.get_installed_game(game.app_name)
- if old_igame and self.repair and os.path.exists(self.repair_file):
- if old_igame.needs_verification:
- old_igame.needs_verification = False
- self.core.install_game(old_igame)
+ if (
+ self.item.download.game.supports_cloud_saves
+ or self.item.download.game.supports_mac_cloud_saves
+ ) and not self.item.download.game.is_dlc:
+ ret.sync_saves = True
- logger.debug("Removing repair file.")
- os.remove(self.repair_file)
- if old_igame and old_igame.install_tags != self.igame.install_tags:
- old_igame.install_tags = self.igame.install_tags
- self.logger.info("Deleting now untagged files.")
- self.core.uninstall_tag(old_igame)
- self.core.install_game(old_igame)
+ # show tip again after installation finishes so users hopefully actually see it
+ if tip_url := self.core.get_game_tip(self.item.download.igame.app_name):
+ ret.tip_url = tip_url
- if not self.queue_item.options.update and self.queue_item.options.create_shortcut:
- if not create_desktop_link(self.queue_item.options.app_name, self.core, "desktop"):
- # maybe add it to download summary, to show in finished downloads
- pass
- else:
- logger.info("Desktop shortcut written")
+ LegendaryCLI(self.core).install_game_cleanup(
+ self.item.download.game,
+ self.item.download.igame,
+ self.item.download.repair,
+ self.item.download.repair_file,
+ )
- self.status.emit("finish")
+ if not self.item.options.update and self.item.options.create_shortcut:
+ ret.shortcuts = True
+
+ self.ret_status.emit(ret)
def _handle_postinstall(self, postinstall, igame):
- logger.info(f"Postinstall info: {postinstall}")
+ logger.info("This game lists the following prequisites to be installed:")
+ logger.info(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}')
if platform.system() == "Windows":
- if self.queue_item.options.install_preqs:
+ if not self.item.options.install_preqs:
+ logger.info("Marking prerequisites as installed...")
+ self.core.prereq_installed(self.item.download.igame.app_name)
+ else:
+ logger.info("Launching prerequisite executable..")
self.core.prereq_installed(igame.app_name)
req_path, req_exec = os.path.split(postinstall["path"])
work_dir = os.path.join(igame.install_path, req_path)
@@ -211,15 +152,14 @@ class DownloadThread(QThread):
proc = QProcess()
proc.setProcessChannelMode(QProcess.MergedChannels)
proc.readyReadStandardOutput.connect(
- lambda: logger.debug(
- str(proc.readAllStandardOutput().data(), "utf-8", "ignore")
- ))
- proc.start(fullpath, postinstall.get("args", []))
+ lambda: logger.debug(str(proc.readAllStandardOutput().data(), "utf-8", "ignore"))
+ )
+ proc.setNativeArguments(postinstall.get("args", []))
+ proc.setWorkingDirectory(work_dir)
+ proc.start(fullpath)
proc.waitForFinished() # wait, because it is inside the thread
- else:
- self.core.prereq_installed(self.igame.app_name)
else:
logger.info("Automatic installation not available on Linux.")
def kill(self):
- self._kill = True
+ self.dlm_signals.kill = True
diff --git a/rare/components/tabs/games/__init__.py b/rare/components/tabs/games/__init__.py
index 28b7f98e..e6db3967 100644
--- a/rare/components/tabs/games/__init__.py
+++ b/rare/components/tabs/games/__init__.py
@@ -139,6 +139,16 @@ class GamesTab(QStackedWidget):
self.view_stack.setCurrentWidget(self.icon_view_scroll)
self.head_bar.search_bar.textChanged.connect(lambda x: self.filter_games("", x))
+ self.head_bar.search_bar.textChanged.connect(
+ lambda x: self.icon_view_scroll.verticalScrollBar().setSliderPosition(
+ self.icon_view_scroll.verticalScrollBar().minimum()
+ )
+ )
+ self.head_bar.search_bar.textChanged.connect(
+ lambda x: self.list_view_scroll.verticalScrollBar().setSliderPosition(
+ self.list_view_scroll.verticalScrollBar().minimum()
+ )
+ )
self.head_bar.filterChanged.connect(self.filter_games)
self.head_bar.refresh_list.clicked.connect(self.update_list)
self.head_bar.view.toggled.connect(self.toggle_view)
@@ -320,8 +330,8 @@ class GamesTab(QStackedWidget):
visible = True
if (
- search_text not in widget.game.app_name.lower()
- and search_text not in widget.game.app_title.lower()
+ search_text.lower() not in widget.game.app_name.lower()
+ and search_text.lower() not in widget.game.app_title.lower()
):
opacity = 0.25
else:
@@ -345,7 +355,7 @@ class GamesTab(QStackedWidget):
# lk: it sorts by installed then by title
installing_widget = self.icon_view.layout().remove(type(self.installing_widget).__name__)
if sort_by:
- self.icon_view.layout().sort(lambda x: (sort_by not in x.widget().game.app_title.lower(),))
+ self.icon_view.layout().sort(lambda x: (sort_by.lower() not in x.widget().game.app_title.lower(),))
else:
self.icon_view.layout().sort(
lambda x: (
diff --git a/rare/components/tabs/games/cloud_save_utils.py b/rare/components/tabs/games/cloud_save_utils.py
index ff50b39b..53832633 100644
--- a/rare/components/tabs/games/cloud_save_utils.py
+++ b/rare/components/tabs/games/cloud_save_utils.py
@@ -11,7 +11,7 @@ from legendary.core import LegendaryCore
from legendary.models.game import SaveGameStatus, InstalledGame, SaveGameFile
from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton, ApiResultsSingleton
from rare.ui.components.dialogs.sync_save_dialog import Ui_SyncSaveDialog
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = getLogger("Cloud Saves")
diff --git a/rare/components/tabs/games/game_info/game_dlc.py b/rare/components/tabs/games/game_info/game_dlc.py
index 7ed75528..192cd28c 100644
--- a/rare/components/tabs/games/game_info/game_dlc.py
+++ b/rare/components/tabs/games/game_info/game_dlc.py
@@ -7,7 +7,7 @@ from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.shared.image_manager import ImageManagerSingleton, ImageSize
from rare.ui.components.tabs.games.game_info.game_dlc import Ui_GameDlc
from rare.ui.components.tabs.games.game_info.game_dlc_widget import Ui_GameDlcWidget
-from rare.utils.models import InstallOptionsModel
+from rare.models.install import InstallOptionsModel
from rare.widgets.image_widget import ImageWidget
diff --git a/rare/components/tabs/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py
index fa303b2e..9f8fa904 100644
--- a/rare/components/tabs/games/game_info/game_info.py
+++ b/rare/components/tabs/games/game_info/game_info.py
@@ -37,9 +37,9 @@ from rare.shared.image_manager import ImageManagerSingleton, ImageSize
from rare.ui.components.tabs.games.game_info.game_info import Ui_GameInfo
from rare.utils.extra_widgets import PathEdit
from rare.utils.legendary_utils import VerifyWorker
-from rare.utils.models import InstallOptionsModel
+from rare.models.install import InstallOptionsModel
from rare.utils.steam_grades import SteamWorker
-from rare.utils.utils import get_size
+from rare.utils.misc import get_size
from rare.widgets.image_widget import ImageWidget
logger = getLogger("GameInfo")
@@ -117,77 +117,111 @@ class GameInfo(QWidget, Ui_GameInfo):
self.game_utils.update_list.emit(self.game.app_name)
self.uninstalled.emit(self.game.app_name)
+ @pyqtSlot()
def repair(self):
- repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.game.app_name}.repair")
+ """ This function is to be called from the button only """
+ repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.igame.app_name}.repair")
if not os.path.exists(repair_file):
QMessageBox.warning(
self,
- "Warning",
+ self.tr("Error - {}").format(self.igame.title),
self.tr(
"Repair file does not exist or game does not need a repair. Please verify game first"
),
)
return
+ self.repair_game(self.igame)
+
+ def repair_game(self, igame: InstalledGame):
+ game = self.core.get_game(igame.app_name)
+ ans = False
+ if igame.version != game.app_version(igame.platform):
+ ans = QMessageBox.question(
+ self,
+ self.tr("Repair and update?"),
+ self.tr(
+ "There is an update for {} from {} to {}."
+ "Do you want to update the game while repairing it?"
+ ).format(igame.title, igame.version, game.app_version(igame.platform)),
+ ) == QMessageBox.Yes
self.signals.install_game.emit(
- InstallOptionsModel(app_name=self.game.app_name, repair=True, update=True)
+ InstallOptionsModel(
+ app_name=igame.app_name, repair_mode=True, repair_and_update=ans, update=True
+ )
)
+ @pyqtSlot()
def verify(self):
+ """ This function is to be called from the button only """
if not os.path.exists(self.igame.install_path):
- logger.error("Path does not exist")
+ logger.error(f"Installation path {self.igame.install_path} for {self.igame.title} does not exist")
QMessageBox.warning(
self,
- "Warning",
- self.tr("Installation path of {} does not exist. Cannot verify").format(self.igame.title),
+ self.tr("Error - {}").format(self.igame.title),
+ self.tr("Installation path for {} does not exist. Cannot continue.").format(self.igame.title),
)
return
+ self.verify_game(self.igame)
+
+ def verify_game(self, igame: InstalledGame):
self.verify_widget.setCurrentIndex(1)
- verify_worker = VerifyWorker(self.game.app_name)
- verify_worker.signals.status.connect(self.verify_statistics)
- verify_worker.signals.summary.connect(self.finish_verify)
+ verify_worker = VerifyWorker(igame.app_name)
+ verify_worker.signals.status.connect(self.verify_status)
+ verify_worker.signals.result.connect(self.verify_result)
+ verify_worker.signals.error.connect(self.verify_error)
self.verify_progress.setValue(0)
- self.verify_threads[self.game.app_name] = verify_worker
+ self.verify_threads[igame.app_name] = verify_worker
self.verify_pool.start(verify_worker)
self.move_button.setEnabled(False)
- def verify_statistics(self, num, total, app_name):
+ def verify_cleanup(self, app_name: str):
+ self.verify_widget.setCurrentIndex(0)
+ self.verify_threads.pop(app_name)
+ self.move_button.setEnabled(True)
+ self.verify_button.setEnabled(True)
+
+ @pyqtSlot(str, str)
+ def verify_error(self, app_name, message):
+ self.verify_cleanup(app_name)
+ igame = self.core.get_installed_game(app_name)
+ QMessageBox.warning(
+ self,
+ self.tr("Error - {}").format(igame.title),
+ message
+ )
+
+ @pyqtSlot(str, int, int, float, float)
+ def verify_status(self, app_name, num, total, percentage, speed):
# checked, max, app_name
if app_name == self.game.app_name:
self.verify_progress.setValue(num * 100 // total)
- def finish_verify(self, failed, missing, app_name):
- if failed == missing == 0:
+ @pyqtSlot(str, bool, int, int)
+ def verify_result(self, app_name, success, failed, missing):
+ self.verify_cleanup(app_name)
+ self.repair_button.setDisabled(success)
+ igame = self.core.get_installed_game(app_name)
+ if success:
QMessageBox.information(
self,
- "Summary",
- "Game was verified successfully. No missing or corrupt files found",
+ self.tr("Summary - {}").format(igame.title),
+ self.tr("{} has been verified successfully. "
+ "No missing or corrupt files found").format(igame.title),
)
- igame = self.core.get_installed_game(app_name)
- if igame.needs_verification:
- igame.needs_verification = False
- self.core.lgd.set_installed_game(igame.app_name, igame)
- self.verification_finished.emit(igame)
- elif failed == missing == -1:
- QMessageBox.warning(self, "Warning", self.tr("Something went wrong"))
-
+ self.verification_finished.emit(igame)
else:
ans = QMessageBox.question(
self,
- "Summary",
+ self.tr("Summary - {}").format(igame.title),
self.tr(
- "Verification failed, {} file(s) corrupted, {} file(s) are missing. Do you want to repair them?"
+ "Verification failed, {} file(s) corrupted, {} file(s) are missing. "
+ "Do you want to repair them?"
).format(failed, missing),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if ans == QMessageBox.Yes:
- self.signals.install_game.emit(
- InstallOptionsModel(app_name=app_name, repair=True, update=True)
- )
- self.verify_widget.setCurrentIndex(0)
- self.verify_threads.pop(app_name)
- self.move_button.setEnabled(True)
- self.verify_button.setEnabled(True)
+ self.repair_game(igame)
@pyqtSlot(str)
def move_game(self, dest_path):
@@ -318,7 +352,9 @@ class GameInfo(QWidget, Ui_GameInfo):
self.uninstall_button.setDisabled(False)
self.verify_button.setDisabled(False)
if not self.args.offline:
- self.repair_button.setDisabled(False)
+ self.repair_button.setDisabled(
+ not os.path.exists(os.path.join(self.core.lgd.get_tmp_path(), f"{self.igame.app_name}.repair"))
+ )
self.game_actions_stack.setCurrentIndex(0)
try:
diff --git a/rare/components/tabs/games/game_info/game_settings.py b/rare/components/tabs/games/game_info/game_settings.py
index d3b0be90..3808b9cc 100644
--- a/rare/components/tabs/games/game_info/game_settings.py
+++ b/rare/components/tabs/games/game_info/game_settings.py
@@ -10,7 +10,7 @@ from rare.components.tabs.settings import DefaultGameSettings
from rare.components.tabs.settings.widgets.pre_launch import PreLaunchSettings
from rare.utils import config_helper
from rare.utils.extra_widgets import PathEdit
-from rare.utils.utils import icon, WineResolver, get_raw_save_path
+from rare.utils.misc import icon, WineResolver, get_raw_save_path
logger = getLogger("GameSettings")
diff --git a/rare/components/tabs/games/game_info/uninstalled_info.py b/rare/components/tabs/games/game_info/uninstalled_info.py
index 79c582c9..5c99e648 100644
--- a/rare/components/tabs/games/game_info/uninstalled_info.py
+++ b/rare/components/tabs/games/game_info/uninstalled_info.py
@@ -15,7 +15,7 @@ from rare.shared.image_manager import ImageManagerSingleton, ImageSize
from rare.ui.components.tabs.games.game_info.game_info import Ui_GameInfo
from rare.utils.extra_widgets import SideTabWidget
from rare.utils.json_formatter import QJsonModel
-from rare.utils.models import InstallOptionsModel
+from rare.models.install import InstallOptionsModel
from rare.utils.steam_grades import SteamWorker
from rare.widgets.image_widget import ImageWidget
diff --git a/rare/components/tabs/games/game_utils.py b/rare/components/tabs/games/game_utils.py
index 56b6bf1c..81394eb3 100644
--- a/rare/components/tabs/games/game_utils.py
+++ b/rare/components/tabs/games/game_utils.py
@@ -13,7 +13,7 @@ from rare.components.tabs.games import CloudSaveUtils
from rare.game_launch_helper import message_models
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
from rare.utils import legendary_utils
-from rare.utils import utils
+from rare.utils import misc
from rare.utils.meta import RareGameMeta
logger = getLogger("GameUtils")
@@ -24,7 +24,7 @@ class GameProcess(QObject):
game_launched = pyqtSignal(str)
tried_connections = 0
- def __init__(self, app_name: str, on_startup=False, always_ask_sync: bool= False):
+ def __init__(self, app_name: str, on_startup=False, always_ask_sync: bool = False):
super(GameProcess, self).__init__()
self.app_name = app_name
self.on_startup = on_startup
@@ -152,7 +152,7 @@ class GameUtils(QObject):
if not os.path.exists(igame.install_path):
if QMessageBox.Yes == QMessageBox.question(
None,
- "Uninstall",
+ self.tr("Uninstall - {}").format(igame.title),
self.tr(
"Game files of {} do not exist. Remove it from installed games?"
).format(igame.title),
@@ -164,10 +164,12 @@ class GameUtils(QObject):
else:
return False
- infos = UninstallDialog(game).get_information()
- if infos == 0:
+ proceed, keep_files, keep_config = UninstallDialog(game).get_options()
+ if not proceed:
return False
- legendary_utils.uninstall(game.app_name, self.core, infos)
+ success, message = legendary_utils.uninstall_game(self.core, game.app_name, keep_files, keep_config)
+ if not success:
+ QMessageBox.warning(None, self.tr("Uninstall - {}").format(igame.title), message, QMessageBox.Close)
self.signals.game_uninstalled.emit(app_name)
return True
@@ -206,7 +208,7 @@ class GameUtils(QObject):
wine_pfx: str = None,
ask_always_sync: bool = False,
):
- executable = utils.get_rare_executable()
+ executable = misc.get_rare_executable()
executable, args = executable[0], executable[1:]
args.extend([
"start", app_name
diff --git a/rare/components/tabs/games/game_widgets/base_installed_widget.py b/rare/components/tabs/games/game_widgets/base_installed_widget.py
index e7c30b1d..7f1d9837 100644
--- a/rare/components/tabs/games/game_widgets/base_installed_widget.py
+++ b/rare/components/tabs/games/game_widgets/base_installed_widget.py
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QFrame, QMessageBox, QAction
from rare.components.tabs.games.game_utils import GameUtils
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
from rare.shared.image_manager import ImageManagerSingleton, ImageSize
-from rare.utils.utils import create_desktop_link
+from rare.utils.misc import create_desktop_link
from rare.widgets.image_widget import ImageWidget
logger = getLogger("Game")
diff --git a/rare/components/tabs/games/game_widgets/installed_icon_widget.py b/rare/components/tabs/games/game_widgets/installed_icon_widget.py
index 7b995089..f6b84680 100644
--- a/rare/components/tabs/games/game_widgets/installed_icon_widget.py
+++ b/rare/components/tabs/games/game_widgets/installed_icon_widget.py
@@ -9,7 +9,7 @@ from rare.components.tabs.games.game_widgets.base_installed_widget import (
)
from rare.shared import LegendaryCoreSingleton
from rare.shared.image_manager import ImageSize
-from rare.utils.utils import icon
+from rare.utils.misc import icon
from rare.widgets.elide_label import ElideLabel
logger = getLogger("GameWidgetInstalled")
@@ -43,7 +43,7 @@ class InstalledIconWidget(BaseInstalledWidget):
minilayout.setSpacing(0)
miniwidget.setLayout(minilayout)
- self.title_label = ElideLabel(f"
{self.game.app_title}
", parent=miniwidget)
+ self.title_label = ElideLabel(f"{self.game.app_title}", parent=miniwidget)
self.title_label.setAlignment(Qt.AlignTop)
self.title_label.setObjectName("game_widget")
minilayout.addWidget(self.title_label, stretch=2)
diff --git a/rare/components/tabs/games/game_widgets/installed_list_widget.py b/rare/components/tabs/games/game_widgets/installed_list_widget.py
index 0950a61d..c4239f02 100644
--- a/rare/components/tabs/games/game_widgets/installed_list_widget.py
+++ b/rare/components/tabs/games/game_widgets/installed_list_widget.py
@@ -6,8 +6,7 @@ from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from rare.components.tabs.games.game_widgets.base_installed_widget import (
BaseInstalledWidget,
)
-from rare.utils.utils import get_size
-from rare.utils.utils import icon
+from rare.utils.misc import icon, get_size
logger = getLogger("GameWidget")
diff --git a/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py b/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py
index f3daf240..c0c2e548 100644
--- a/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py
+++ b/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py
@@ -29,7 +29,7 @@ class UninstalledIconWidget(BaseUninstalledWidget):
minilayout.setSpacing(0)
miniwidget.setLayout(minilayout)
- self.title_label = ElideLabel(f"{game.app_title}
", parent=miniwidget)
+ self.title_label = ElideLabel(f"{game.app_title}", parent=miniwidget)
self.title_label.setAlignment(Qt.AlignTop)
self.title_label.setObjectName("game_widget")
minilayout.addWidget(self.title_label, stretch=2)
diff --git a/rare/components/tabs/games/head_bar.py b/rare/components/tabs/games/head_bar.py
index aaa32134..e22ca957 100644
--- a/rare/components/tabs/games/head_bar.py
+++ b/rare/components/tabs/games/head_bar.py
@@ -10,7 +10,7 @@ from qtawesome import IconWidget
from rare.shared import ApiResultsSingleton
from rare.utils.extra_widgets import SelectViewWidget, ButtonLineEdit
-from rare.utils.utils import icon
+from rare.utils.misc import icon
class GameListHeadBar(QWidget):
diff --git a/rare/components/tabs/games/import_sync/egl_sync_group.py b/rare/components/tabs/games/import_sync/egl_sync_group.py
index 56df606c..79d3ab4d 100644
--- a/rare/components/tabs/games/import_sync/egl_sync_group.py
+++ b/rare/components/tabs/games/import_sync/egl_sync_group.py
@@ -6,6 +6,7 @@ from typing import Tuple, Iterable, List
from PyQt5.QtCore import Qt, QThreadPool, QRunnable, pyqtSlot, pyqtSignal
from PyQt5.QtWidgets import QGroupBox, QListWidgetItem, QFileDialog, QMessageBox
+from rare.lgndr.api_exception import LgndrException
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.ui.components.tabs.games.import_sync.egl_sync_group import Ui_EGLSyncGroup
from rare.ui.components.tabs.games.import_sync.egl_sync_list_group import (
@@ -13,7 +14,7 @@ from rare.ui.components.tabs.games.import_sync.egl_sync_list_group import (
)
from rare.utils.extra_widgets import PathEdit
from rare.utils.models import PathSpec
-from rare.utils.utils import WineResolver
+from rare.utils.misc import WineResolver
logger = getLogger("EGLSync")
@@ -183,11 +184,18 @@ class EGLSyncListItem(QListWidgetItem):
def is_checked(self) -> bool:
return self.checkState() == Qt.Checked
- def action(self) -> None:
+ def action(self) -> str:
+ error = ""
if self.export:
- error = self.core.egl_export(self.game.app_name)
+ try:
+ self.core.egl_export(self.game.app_name)
+ except LgndrException as ret:
+ error = ret.message
else:
- error = self.core.egl_import(self.game.app_name)
+ try:
+ self.core.egl_import(self.game.app_name)
+ except LgndrException as ret:
+ error = ret.message
return error
@property
@@ -307,85 +315,3 @@ class EGLSyncWorker(QRunnable):
def run(self):
self.import_list.action()
self.export_list.action()
-
-
-"""
-from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QCheckBox, QPushButton, QDialog
-
-
-class DisableSyncDialog(QDialog):
- info = 1, False
-
- def __init__(self, parent=None):
- super(DisableSyncDialog, self).__init__(parent=parent)
- self.layout = QVBoxLayout()
-
- self.question = QLabel(self.tr("Do you really want to disable sync with Epic Games Store"))
- self.layout.addWidget(self.question)
-
- self.remove_metadata = QCheckBox(self.tr("Remove metadata from installed games"))
- self.layout.addWidget(self.remove_metadata)
-
- self.button_layout = QHBoxLayout()
- self.button_layout.addStretch(1)
-
- self.ok_button = QPushButton(self.tr("Ok"))
- self.cancel_button = QPushButton(self.tr("Cancel"))
-
- self.ok_button.clicked.connect(self.ok)
- self.cancel_button.clicked.connect(self.cancel)
-
- self.button_layout.addWidget(self.ok_button)
- self.button_layout.addWidget(self.cancel_button)
-
- self.layout.addStretch(1)
- self.layout.addLayout(self.button_layout)
-
- self.setLayout(self.layout)
-
- def ok(self):
- self.info = 0, self.remove_metadata.isChecked()
- self.close()
-
- def cancel(self):
- self.close()
-
- def get_information(self):
- self.exec_()
- return self.info
-
-
-class EGLSyncItemWidget(QGroupBox):
- def __init__(self, game, export: bool, parent=None):
- super(EGLSyncItemWidget, self).__init__(parent=parent)
- self.layout = QHBoxLayout()
- self.export = export
- self.game = game
- if export:
- self.app_title_label = QLabel(game.title)
- else:
- title = self.core.get_game(game.app_name).app_title
- self.app_title_label = QLabel(title)
- self.layout.addWidget(self.app_title_label)
- self.button = QPushButton(self.tr("Export") if export else self.tr("Import"))
-
- if export:
- self.button.clicked.connect(self.export_game)
- else:
- self.button.clicked.connect(self.import_game)
-
- self.layout.addWidget(self.button)
- self.setLayout(self.layout)
-
- def export_game(self):
- self.core.egl_export(self.game.app_name)
- # FIXME: on update_egl_widget this is going to crash because
- # FIXME: the item is not removed from the list in the python's side
- self.deleteLater()
-
- def import_game(self):
- self.core.egl_import(self.game.app_name)
- # FIXME: on update_egl_widget this is going to crash because
- # FIXME: the item is not removed from the list in the python's side
- self.deleteLater()
-"""
diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py
index 3726aa62..c4315a6d 100644
--- a/rare/components/tabs/games/import_sync/import_group.py
+++ b/rare/components/tabs/games/import_sync/import_group.py
@@ -10,10 +10,13 @@ from PyQt5.QtCore import Qt, QModelIndex, pyqtSignal, QRunnable, QObject, QThrea
from PyQt5.QtGui import QStandardItemModel
from PyQt5.QtWidgets import QFileDialog, QGroupBox, QCompleter, QTreeView, QHeaderView, qApp, QMessageBox
+from rare.lgndr.cli import LegendaryCLI
+from rare.lgndr.api_arguments import LgndrImportGameArgs
+from rare.lgndr.api_monkeys import LgndrIndirectStatus
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton
from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup
-from rare.utils import legendary_utils
from rare.utils.extra_widgets import IndicatorLineEdit, PathEdit
+from rare.widgets.elide_label import ElideLabel
logger = getLogger("Import")
@@ -25,8 +28,7 @@ def find_app_name(path: str, core) -> Optional[str]:
with open(os.path.join(path, ".egstore", i)) as file:
app_name = json.load(file).get("AppName")
return app_name
- elif app_name := legendary_utils.resolve_aliases(
- core, os.path.basename(os.path.normpath(path))):
+ elif app_name := LegendaryCLI(core).resolve_aliases(os.path.basename(os.path.normpath(path))):
# return None if game does not exist (Workaround for overlay)
if not core.get_game(app_name):
return None
@@ -45,7 +47,9 @@ class ImportResult(IntEnum):
@dataclass
class ImportedGame:
result: ImportResult
+ path: Optional[str] = None
app_name: Optional[str] = None
+ app_title: Optional[str] = None
message: Optional[str] = None
@@ -54,14 +58,15 @@ class ImportWorker(QRunnable):
finished = pyqtSignal(list)
progress = pyqtSignal(int)
- def __init__(self, path: str, import_folder: bool = False, app_name: str = None):
+ def __init__(self, path: str, app_name: str = None, import_folder: bool = False, import_dlcs: bool = False):
super(ImportWorker, self).__init__()
self.signals = self.Signals()
self.core = LegendaryCoreSingleton()
+
self.path = Path(path)
- self.import_folder = import_folder
self.app_name = app_name
- self.tr = lambda message: qApp.translate("ImportThread", message)
+ self.import_folder = import_folder
+ self.import_dlcs = import_dlcs
def run(self) -> None:
result_list: List = []
@@ -80,26 +85,30 @@ class ImportWorker(QRunnable):
self.signals.finished.emit(result_list)
def __try_import(self, path: Path, app_name: str = None) -> ImportedGame:
- result = ImportedGame(ImportResult.ERROR, None, None)
+ result = ImportedGame(ImportResult.ERROR)
+ result.path = str(path)
if app_name or (app_name := find_app_name(str(path), self.core)):
result.app_name = app_name
- err = self.__import_game(app_name, path)
- if err:
+ result.app_title = app_title = self.core.get_game(app_name).app_title
+ success, message = self.__import_game(path, app_name, app_title)
+ if not success:
result.result = ImportResult.FAILED
- result.message = err
+ result.message = message
else:
result.result = ImportResult.SUCCESS
- else:
- result.message = self.tr("Could not find AppName for {}").format(str(path))
return result
- def __import_game(self, app_name: str, path: Path) -> str:
- if not (err := legendary_utils.import_game(self.core, app_name=app_name, path=str(path))):
- igame = self.core.get_installed_game(app_name)
- logger.info(f"Successfully imported {igame.title}")
- return ""
- else:
- return err
+ def __import_game(self, path: Path, app_name: str, app_title: str):
+ cli = LegendaryCLI(self.core)
+ status = LgndrIndirectStatus()
+ args = LgndrImportGameArgs(
+ app_path=str(path),
+ app_name=app_name,
+ indirect_status=status,
+ get_boolean_choice=lambda prompt, default=True: self.import_dlcs
+ )
+ cli.import_game(args)
+ return status.success, status.message
class AppNameCompleter(QCompleter):
@@ -181,17 +190,17 @@ class ImportGroup(QGroupBox):
self.app_name_edit.textChanged.connect(self.app_name_changed)
self.ui.app_name_layout.addWidget(self.app_name_edit)
+ self.ui.import_folder_check.stateChanged.connect(self.import_folder_changed)
+ self.ui.import_dlcs_check.setEnabled(False)
+
self.ui.import_button.setEnabled(False)
self.ui.import_button.clicked.connect(
lambda: self.import_pressed(self.path_edit.text())
)
- self.ui.import_folder_check.stateChanged.connect(
- lambda s: self.ui.import_button.setEnabled(s or (not s and self.app_name_edit.is_valid))
- )
- self.ui.import_folder_check.stateChanged.connect(
- lambda s: self.app_name_edit.setEnabled(not s)
- )
+ self.info_label = ElideLabel(text="", parent=self)
+ self.ui.button_info_layout.addWidget(self.info_label)
+
self.threadpool = QThreadPool.globalInstance()
def path_edit_cb(self, path) -> Tuple[bool, str, str]:
@@ -205,8 +214,8 @@ class ImportGroup(QGroupBox):
return False, path, ""
def path_changed(self, path):
- self.ui.info_label.setText("")
- self.ui.import_folder_check.setChecked(False)
+ self.info_label.setText("")
+ self.ui.import_folder_check.setCheckState(Qt.Unchecked)
if self.path_edit.is_valid:
self.app_name_edit.setText(find_app_name(path, self.core))
else:
@@ -220,17 +229,36 @@ class ImportGroup(QGroupBox):
else:
return False, text, IndicatorLineEdit.reasons.game_not_installed
- def app_name_changed(self, text):
- self.ui.info_label.setText("")
+ def app_name_changed(self, app_name: str):
+ self.info_label.setText("")
+ self.ui.import_dlcs_check.setCheckState(Qt.Unchecked)
if self.app_name_edit.is_valid:
+ self.ui.import_dlcs_check.setEnabled(
+ bool(self.core.get_dlc_for_game(app_name))
+ )
self.ui.import_button.setEnabled(True)
else:
+ self.ui.import_dlcs_check.setEnabled(False)
self.ui.import_button.setEnabled(False)
+ def import_folder_changed(self, state):
+ self.app_name_edit.setEnabled(not state)
+ self.ui.import_dlcs_check.setCheckState(Qt.Unchecked)
+ self.ui.import_dlcs_check.setEnabled(
+ state
+ or (self.app_name_edit.is_valid and bool(self.core.get_dlc_for_game(self.app_name_edit.text())))
+ )
+ self.ui.import_button.setEnabled(state or (not state and self.app_name_edit.is_valid))
+
def import_pressed(self, path=None):
if not path:
path = self.path_edit.text()
- worker = ImportWorker(path, self.ui.import_folder_check.isChecked(), self.app_name_edit.text())
+ worker = ImportWorker(
+ path,
+ self.app_name_edit.text(),
+ self.ui.import_folder_check.isChecked(),
+ self.ui.import_dlcs_check.isChecked(),
+ )
worker.signals.finished.connect(self.import_finished)
worker.signals.progress.connect(self.import_progress)
self.threadpool.start(worker)
@@ -251,16 +279,16 @@ class ImportGroup(QGroupBox):
if len(result) == 1:
res = result[0]
if res.result == ImportResult.SUCCESS:
- self.ui.info_label.setText(
- self.tr("{} was imported successfully").format(self.core.get_game(res.app_name).app_title)
+ self.info_label.setText(
+ self.tr("Success: {} imported").format(res.app_title)
)
elif res.result == ImportResult.FAILED:
- self.ui.info_label.setText(
- self.tr("Failed: {}").format(res.message)
+ self.info_label.setText(
+ self.tr("Failed: {} - {}").format(res.app_title, res.message)
)
else:
- self.ui.info_label.setText(
- self.tr("Error: {}").format(res.message)
+ self.info_label.setText(
+ self.tr("Error: Could not find AppName for {}").format(res.path)
)
else:
success = [r for r in result if r.result == ImportResult.SUCCESS]
@@ -280,15 +308,15 @@ class ImportGroup(QGroupBox):
details: List = []
for res in success:
details.append(
- self.tr("{} was imported successfully").format(self.core.get_game(res.app_name).app_title)
+ self.tr("Success: {} imported").format(res.app_title)
)
for res in failure:
details.append(
- self.tr("Failed: {}").format(res.message)
+ self.tr("Failed: {} - {}").format(res.app_title, res.message)
)
for res in errored:
details.append(
- self.tr("Error: {}").format(res.message)
+ self.tr("Error: Could not find AppName for {}").format(res.path)
)
messagebox.setDetailedText("\n".join(details))
messagebox.show()
diff --git a/rare/components/tabs/settings/__init__.py b/rare/components/tabs/settings/__init__.py
index a4596e12..436e9d9b 100644
--- a/rare/components/tabs/settings/__init__.py
+++ b/rare/components/tabs/settings/__init__.py
@@ -1,11 +1,9 @@
-import platform
-
+from rare.components.tabs.settings.widgets.linux import LinuxSettings
from rare.utils.extra_widgets import SideTabWidget
from .about import About
-from .legendary import LegendarySettings
-from rare.components.tabs.settings.widgets.linux import LinuxSettings
-from .rare import RareSettings
from .default_game_settings import DefaultGameSettings
+from .legendary import LegendarySettings
+from .rare import RareSettings
class SettingsTab(SideTabWidget):
diff --git a/rare/components/tabs/settings/legendary.py b/rare/components/tabs/settings/legendary.py
index 1f7c3435..51f22e88 100644
--- a/rare/components/tabs/settings/legendary.py
+++ b/rare/components/tabs/settings/legendary.py
@@ -11,7 +11,7 @@ from rare.components.tabs.settings.widgets.ubisoft_activation import UbiActivati
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.legendary import Ui_LegendarySettings
from rare.utils.extra_widgets import PathEdit, IndicatorLineEdit
-from rare.utils.utils import get_size
+from rare.utils.misc import get_size
logger = getLogger("LegendarySettings")
@@ -174,10 +174,10 @@ class LegendarySettings(QWidget, Ui_LegendarySettings):
if not keep_manifests:
logger.debug("Removing manifests...")
installed = [
- (ig.app_name, ig.version) for ig in self.core.get_installed_list()
+ (ig.app_name, ig.version, ig.platform) for ig in self.core.get_installed_list()
]
installed.extend(
- (ig.app_name, ig.version) for ig in self.core.get_installed_dlc_list()
+ (ig.app_name, ig.version, ig.platform) for ig in self.core.get_installed_dlc_list()
)
self.core.lgd.clean_manifests(installed)
diff --git a/rare/components/tabs/settings/rare.py b/rare/components/tabs/settings/rare.py
index 187fb94d..48bdc573 100644
--- a/rare/components/tabs/settings/rare.py
+++ b/rare/components/tabs/settings/rare.py
@@ -10,14 +10,15 @@ from PyQt5.QtWidgets import QWidget, QMessageBox
from rare.shared import LegendaryCoreSingleton
from rare.components.tabs.settings.widgets.rpc import RPCSettings
from rare.ui.components.tabs.settings.rare import Ui_RareSettings
-from rare.utils import utils
from rare.utils.paths import cache_dir
-from rare.utils.utils import (
+from rare.utils.misc import (
get_translations,
get_color_schemes,
set_color_pallete,
get_style_sheets,
set_style_sheet,
+ get_size,
+ create_desktop_link,
)
logger = getLogger("RareSettings")
@@ -148,7 +149,7 @@ class RareSettings(QWidget, Ui_RareSettings):
for i in os.listdir(logdir):
size += os.path.getsize(os.path.join(logdir, i))
- self.log_dir_size_label.setText(utils.get_size(size))
+ self.log_dir_size_label.setText(get_size(size))
# self.log_dir_clean_button.setVisible(False)
# self.log_dir_size_label.setVisible(False)
@@ -160,7 +161,7 @@ class RareSettings(QWidget, Ui_RareSettings):
def create_start_menu_link(self):
try:
if not os.path.exists(self.start_menu_link):
- utils.create_desktop_link(type_of_link="start_menu", for_rare=True)
+ create_desktop_link(type_of_link="start_menu", for_rare=True)
self.startmenu_link_btn.setText(self.tr("Remove start menu link"))
else:
os.remove(self.start_menu_link)
@@ -169,23 +170,24 @@ class RareSettings(QWidget, Ui_RareSettings):
logger.error(str(e))
QMessageBox.warning(
self,
- "Error",
- f"Permission error, cannot remove {self.start_menu_link}",
+ self.tr("Error"),
+ self.tr("Permission error, cannot remove {}").format(self.start_menu_link),
)
def create_desktop_link(self):
try:
if not os.path.exists(self.desktop_file):
- utils.create_desktop_link(type_of_link="desktop", for_rare=True)
+ create_desktop_link(type_of_link="desktop", for_rare=True)
self.desktop_link_btn.setText(self.tr("Remove Desktop link"))
else:
os.remove(self.desktop_file)
self.desktop_link_btn.setText(self.tr("Create desktop link"))
except PermissionError as e:
+ logger.error(str(e))
logger.warning(
self,
- "Error",
- f"Permission error, cannot remove {self.desktop_file}",
+ self.tr("Error"),
+ self.tr("Permission error, cannot remove {}").format(self.start_menu_link),
)
def on_color_select_changed(self, color):
diff --git a/rare/components/tabs/settings/widgets/env_vars.py b/rare/components/tabs/settings/widgets/env_vars.py
index f5d3128e..c5cb9cd5 100644
--- a/rare/components/tabs/settings/widgets/env_vars.py
+++ b/rare/components/tabs/settings/widgets/env_vars.py
@@ -6,7 +6,7 @@ from PyQt5.QtWidgets import QGroupBox, QTableWidgetItem, QMessageBox, QPushButto
from rare.shared import LegendaryCoreSingleton
from rare.ui.components.tabs.settings.widgets.env_vars import Ui_EnvVars
from rare.utils import config_helper
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = getLogger("EnvVars")
diff --git a/rare/components/tabs/settings/widgets/eos.py b/rare/components/tabs/settings/widgets/eos.py
index bd937382..27a064cb 100644
--- a/rare/components/tabs/settings/widgets/eos.py
+++ b/rare/components/tabs/settings/widgets/eos.py
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QGroupBox, QMessageBox
from legendary.utils import eos
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton
from rare.ui.components.tabs.settings.widgets.eos_widget import Ui_EosWidget
-from rare.utils.models import InstallOptionsModel
+from rare.models.install import InstallOptionsModel
logger = getLogger("EOS")
diff --git a/rare/components/tabs/settings/widgets/ubisoft_activation.py b/rare/components/tabs/settings/widgets/ubisoft_activation.py
index 08da43ad..028d3c5c 100644
--- a/rare/components/tabs/settings/widgets/ubisoft_activation.py
+++ b/rare/components/tabs/settings/widgets/ubisoft_activation.py
@@ -7,7 +7,7 @@ from PyQt5.QtWidgets import QWidget, QLabel, QHBoxLayout, QSizePolicy, QPushButt
from legendary.models.game import Game
from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = getLogger("Ubisoft")
diff --git a/rare/components/tabs/settings/widgets/wrapper.py b/rare/components/tabs/settings/widgets/wrapper.py
index 819f6753..45d63f5d 100644
--- a/rare/components/tabs/settings/widgets/wrapper.py
+++ b/rare/components/tabs/settings/widgets/wrapper.py
@@ -11,7 +11,7 @@ from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QInputDialog, QFra
from rare import shared
from rare.ui.components.tabs.settings.widgets.wrapper import Ui_WrapperSettings
from rare.utils import config_helper
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = getLogger("Wrapper Settings")
diff --git a/rare/components/tabs/shop/game_info.py b/rare/components/tabs/shop/game_info.py
index 8b08f9a6..67b69b65 100644
--- a/rare/components/tabs/shop/game_info.py
+++ b/rare/components/tabs/shop/game_info.py
@@ -17,7 +17,7 @@ from rare.shared import LegendaryCoreSingleton
from rare.components.tabs.shop.shop_models import ShopGame
from rare.ui.components.tabs.store.shop_game_info import Ui_shop_info
from rare.utils.extra_widgets import WaitingSpinner, ImageLabel
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = logging.getLogger("ShopInfo")
diff --git a/rare/components/tabs/shop/game_widgets.py b/rare/components/tabs/shop/game_widgets.py
index 9e985a94..0da342d2 100644
--- a/rare/components/tabs/shop/game_widgets.py
+++ b/rare/components/tabs/shop/game_widgets.py
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout
from rare.components.tabs.shop.shop_models import ImageUrlModel
from rare.ui.components.tabs.store.wishlist_widget import Ui_WishlistWidget
from rare.utils.extra_widgets import ImageLabel
-from rare.utils.utils import icon
+from rare.utils.misc import icon
logger = logging.getLogger("GameWidgets")
diff --git a/rare/components/tabs/shop/wishlist.py b/rare/components/tabs/shop/wishlist.py
index d9b40c4a..df1cae19 100644
--- a/rare/components/tabs/shop/wishlist.py
+++ b/rare/components/tabs/shop/wishlist.py
@@ -5,7 +5,7 @@ from rare.components.tabs.shop import ShopApiCore
from rare.components.tabs.shop.game_widgets import WishlistWidget
from rare.ui.components.tabs.store.wishlist import Ui_Wishlist
from rare.utils.extra_widgets import WaitingSpinner
-from rare.utils.utils import icon
+from rare.utils.misc import icon
class Wishlist(QStackedWidget, Ui_Wishlist):
diff --git a/rare/components/tabs/tab_utils.py b/rare/components/tabs/tab_utils.py
index 8d545352..b419011a 100644
--- a/rare/components/tabs/tab_utils.py
+++ b/rare/components/tabs/tab_utils.py
@@ -1,7 +1,7 @@
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QTabBar, QToolButton
-from rare.utils.utils import icon
+from rare.utils.misc import icon
class MainTabBar(QTabBar):
diff --git a/rare/game_launch_helper/__init__.py b/rare/game_launch_helper/__init__.py
index 12aa226c..6b26435d 100644
--- a/rare/game_launch_helper/__init__.py
+++ b/rare/game_launch_helper/__init__.py
@@ -69,7 +69,7 @@ class GameProcessApp(RareApp):
self.game_process = QProcess()
self.app_name = app_name
self.logger = getLogger(self.app_name)
- self.core = LegendaryCoreSingleton(True)
+ self.core = LegendaryCoreSingleton(init=True)
lang = self.settings.value("language", self.core.language_code, type=str)
self.load_translator(lang)
diff --git a/rare/legendary b/rare/legendary
deleted file mode 160000
index 50f71cbd..00000000
--- a/rare/legendary
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 50f71cbd9b2ae0b31615e8f7d8d8595922d3cdf3
diff --git a/rare/lgndr/__init__.py b/rare/lgndr/__init__.py
new file mode 100644
index 00000000..18d65d6b
--- /dev/null
+++ b/rare/lgndr/__init__.py
@@ -0,0 +1,5 @@
+"""
+Module that overloads and monkeypatches legendary's classes/methods to work with Rare
+
+Files with the 'api_' prefix are not part of legendary's source, and contain facilities relating to Rare.
+"""
\ No newline at end of file
diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py
new file mode 100644
index 00000000..abbe9bba
--- /dev/null
+++ b/rare/lgndr/api_arguments.py
@@ -0,0 +1,136 @@
+from dataclasses import dataclass
+from enum import IntEnum
+from typing import Callable, List, Optional, Dict
+
+from .api_monkeys import (
+ LgndrIndirectStatus,
+ GetBooleanChoiceProtocol,
+ get_boolean_choice,
+ verify_stdout,
+ DLManagerSignals
+)
+from .downloading import UIUpdate
+
+"""
+@dataclass(kw_only=True)
+class LgndrCommonArgs:
+ # keep this here for future reference
+ # when we move to 3.10 we can use 'kw_only' to do dataclass inheritance
+ app_name: str
+ platform: str = "Windows"
+ yes: bool = False
+"""
+
+
+@dataclass
+class LgndrImportGameArgs:
+ app_path: str
+ app_name: str
+ platform: str = "Windows"
+ disable_check: bool = False
+ skip_dlcs: bool = False
+ with_dlcs: bool = False
+ yes: bool = False
+ # Rare: Extra arguments
+ indirect_status: LgndrIndirectStatus = LgndrIndirectStatus()
+ get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice
+
+
+@dataclass
+class LgndrUninstallGameArgs:
+ app_name: str
+ keep_files: bool = False
+ yes: bool = False
+ # Rare: Extra arguments
+ indirect_status: LgndrIndirectStatus = LgndrIndirectStatus()
+ get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice
+
+
+@dataclass
+class LgndrVerifyGameArgs:
+ app_name: str
+ # Rare: Extra arguments
+ indirect_status: LgndrIndirectStatus = LgndrIndirectStatus()
+ verify_stdout: Callable[[int, int, float, float], None] = verify_stdout
+
+
+@dataclass
+class LgndrInstallGameArgs:
+ app_name: str
+ base_path: str = ""
+ shared_memory: int = 0
+ max_workers: int = 0
+ force: bool = False
+ disable_patching: bool = False
+ game_folder: str = ""
+ override_manifest: str = ""
+ override_old_manifest: str = ""
+ override_base_url: str = ""
+ platform: str = "Windows"
+ file_prefix: List = None
+ file_exclude_prefix: List = None
+ install_tag: Optional[List[str]] = None
+ order_opt: bool = False
+ dl_timeout: int = 10
+ repair_mode: bool = False
+ repair_and_update: bool = False
+ disable_delta: bool = False
+ override_delta_manifest: str = ""
+ egl_guid: str = ""
+ preferred_cdn: str = None
+ no_install: bool = False
+ ignore_space: bool = False
+ disable_sdl: bool = False
+ reset_sdl: bool = False
+ skip_sdl: bool = False
+ disable_https: bool = False
+ # FIXME: move to LgndrInstallGameRealArgs
+ skip_dlcs: bool = False
+ with_dlcs: bool = False
+ # end of FIXME
+ yes: bool = True
+ # Rare: Extra arguments
+ indirect_status: LgndrIndirectStatus = LgndrIndirectStatus()
+ get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice
+ sdl_prompt: Callable[[str, str], List[str]] = lambda app_name, title: [""]
+ verify_stdout: Callable[[int, int, float, float], None] = verify_stdout
+
+ # def __post_init__(self):
+ # if self.sdl_prompt is None:
+ # self.sdl_prompt: Callable[[str, str], list] = \
+ # lambda app_name, title: self.install_tag if self.install_tag is not None else [""]
+
+
+@dataclass
+class LgndrInstallGameRealArgs:
+ app_name: str
+ platform: str = "Windows"
+ repair_mode: bool = False
+ repair_file: str = ""
+ no_install: bool = False
+ save_path: str = ""
+ skip_dlcs: bool = False
+ with_dlcs: bool = False
+ dlm_debug: bool = False
+ yes: bool = False
+ # Rare: Extra arguments
+ install_preqs: bool = False
+ indirect_status: LgndrIndirectStatus = LgndrIndirectStatus()
+ ui_update: Callable[[UIUpdate], None] = lambda ui: None
+ dlm_signals: DLManagerSignals = DLManagerSignals()
+
+
+@dataclass
+class LgndrInstallGameRealRet:
+ class ReturnCode(IntEnum):
+ ERROR = 1
+ STOPPED = 2
+ FINISHED = 3
+
+ app_name: str
+ ret_code: ReturnCode = ReturnCode.ERROR
+ message: str = ""
+ dlcs: Optional[List[Dict]] = None
+ sync_saves: bool = False
+ tip_url: str = ""
+ shortcuts: bool = False
diff --git a/rare/lgndr/api_exception.py b/rare/lgndr/api_exception.py
new file mode 100644
index 00000000..11779f79
--- /dev/null
+++ b/rare/lgndr/api_exception.py
@@ -0,0 +1,32 @@
+import logging
+import warnings
+
+
+class LgndrException(RuntimeError):
+ def __init__(self, message="Error in Legendary"):
+ self.message = message
+ super(LgndrException, self).__init__(self.message)
+
+
+class LgndrWarning(RuntimeWarning):
+ def __init__(self, message="Warning in Legendary"):
+ self.message = message
+ super(LgndrWarning, self).__init__(self.message)
+
+
+class LgndrCLILogHandler(logging.Handler):
+ def emit(self, record: logging.LogRecord) -> None:
+ # lk: FATAL is the same as CRITICAL
+ if record.levelno == logging.ERROR or record.levelno == logging.CRITICAL:
+ raise LgndrException(record.getMessage())
+ # if record.levelno < logging.ERROR or record.levelno == logging.WARNING:
+ # warnings.warn(record.getMessage())
+
+
+class LgndrCoreLogHandler(logging.Handler):
+ def emit(self, record: logging.LogRecord) -> None:
+ # lk: FATAL is the same as CRITICAL
+ if record.levelno == logging.CRITICAL:
+ raise LgndrException(record.getMessage())
+ # if record.levelno < logging.CRITICAL:
+ # warnings.warn(record.getMessage())
diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py
new file mode 100644
index 00000000..b764b23d
--- /dev/null
+++ b/rare/lgndr/api_monkeys.py
@@ -0,0 +1,106 @@
+import logging
+from dataclasses import dataclass
+
+from typing_extensions import Protocol
+
+
+class GetBooleanChoiceProtocol(Protocol):
+ def __call__(self, prompt: str, default: bool = ...) -> bool:
+ ...
+
+
+def get_boolean_choice(prompt: str, default: bool = True) -> bool:
+ return default
+
+
+def verify_stdout(a0: int, a1: int, a2: float, a3: float) -> None:
+ print(f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r")
+
+
+class DLManagerSignals:
+ _kill = False
+ _update = False
+
+ @property
+ def kill(self) -> bool:
+ self._update = False
+ return self._kill
+
+ @kill.setter
+ def kill(self, value: bool) -> None:
+ self._update = True
+ self._kill = value
+
+ @property
+ def update(self) -> bool:
+ _update = self._update
+ self._update = False
+ return _update
+
+
+@dataclass
+class LgndrIndirectStatus:
+ success: bool = False
+ message: str = ""
+
+ def __len__(self):
+ if self.message:
+ return 2
+ else:
+ return 0
+
+ def __bool__(self):
+ return self.success
+
+ def __getitem__(self, item):
+ if item == 0:
+ return self.success
+ elif item == 1:
+ return self.message
+ else:
+ raise IndexError
+
+ def __iter__(self):
+ return iter((self.success, self.message))
+
+ def __str__(self):
+ return self.message
+
+
+class LgndrIndirectLogger:
+ def __init__(
+ self, status: LgndrIndirectStatus, logger: logging.Logger = None, level: int = logging.ERROR
+ ):
+ self.logger = logger
+ self.level = level
+ self.status = status
+
+ def set_logger(self, logger: logging.Logger):
+ self.logger = logger
+
+ def set_level(self, level: int):
+ self.level = level
+
+ def _log(self, level: int, msg: str):
+ self.status.success = True if level < self.level else False
+ self.status.message = msg
+ if self.logger:
+ self.logger.log(level, msg)
+
+ def debug(self, msg: str):
+ self._log(logging.DEBUG, msg)
+
+ def info(self, msg: str):
+ self._log(logging.INFO, msg)
+
+ def warning(self, msg: str):
+ self._log(logging.WARNING, msg)
+
+ def error(self, msg: str):
+ self._log(logging.ERROR, msg)
+
+ def critical(self, msg: str):
+ self._log(logging.CRITICAL, msg)
+
+ def fatal(self, msg: str):
+ self.critical(msg)
diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py
new file mode 100644
index 00000000..d0331e1e
--- /dev/null
+++ b/rare/lgndr/cli.py
@@ -0,0 +1,623 @@
+import logging
+import os
+import queue
+import subprocess
+import time
+from typing import Optional, Union, Tuple
+
+from legendary.cli import LegendaryCLI as LegendaryCLIReal
+from legendary.models.downloading import AnalysisResult, ConditionCheckResult
+from legendary.models.game import Game, InstalledGame, VerifyResult
+from legendary.utils.lfs import validate_files
+from legendary.utils.selective_dl import get_sdl_appname
+
+from .api_arguments import (
+ LgndrInstallGameArgs,
+ LgndrImportGameArgs,
+ LgndrVerifyGameArgs,
+ LgndrUninstallGameArgs,
+ LgndrInstallGameRealArgs,
+ LgndrInstallGameRealRet,
+)
+from .api_monkeys import LgndrIndirectStatus, LgndrIndirectLogger
+from .core import LegendaryCore
+from .manager import DLManager
+
+
+# fmt: off
+class LegendaryCLI(LegendaryCLIReal):
+
+ # noinspection PyMissingConstructor
+ def __init__(self, core: LegendaryCore):
+ self.core = core
+ self.logger = logging.getLogger('cli')
+ self.logging_queue = None
+ self.ql = self.setup_threaded_logging()
+
+ def __del__(self):
+ self.ql.stop()
+
+ def resolve_aliases(self, name):
+ return super(LegendaryCLI, self)._resolve_aliases(name)
+
+ def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult]]:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger)
+ get_boolean_choice = args.get_boolean_choice
+ sdl_prompt = args.sdl_prompt
+
+ args.app_name = self._resolve_aliases(args.app_name)
+ if self.core.is_installed(args.app_name):
+ igame = self.core.get_installed_game(args.app_name)
+ args.platform = igame.platform
+ if igame.needs_verification and not args.repair_mode:
+ logger.info('Game needs to be verified before updating, switching to repair mode...')
+ args.repair_mode = True
+
+ repair_file = None
+ # Rare: The 'args.no_install' flags is set externally from the InstallDialog
+ if args.repair_mode:
+ args.repair_mode = True
+ args.no_install = args.repair_and_update is False
+ repair_file = os.path.join(self.core.lgd.get_tmp_path(), f'{args.app_name}.repair')
+
+ # Rare: Rare is already logged in
+
+ if args.file_prefix or args.file_exclude_prefix:
+ args.no_install = True
+
+ # Rare: Rare runs updates on already installed games only
+
+ game = self.core.get_game(args.app_name, update_meta=True, platform=args.platform)
+
+ if not game:
+ logger.error(f'Could not find "{args.app_name}" in list of available games, '
+ f'did you type the name correctly?')
+ return
+
+ # Rare: Rare checks this before calling 'install_game'
+
+ if args.platform not in game.asset_infos:
+ if not args.no_install:
+ if self.core.lgd.config.getboolean('Legendary', 'install_platform_fallback', fallback=True):
+ logger.warning(f'App has no asset for platform "{args.platform}", falling back to "Windows".')
+ args.platform = 'Windows'
+ else:
+ logger.error(f'No app asset found for platform "{args.platform}", run '
+ f'"legendary info --platform {args.platform}" and make '
+ f'sure the app is available for the specified platform.')
+ return
+ else:
+ logger.warning(f'No asset found for platform "{args.platform}", '
+ f'trying anyway since --no-install is set.')
+
+ if game.is_dlc:
+ logger.info('Install candidate is DLC')
+ app_name = game.metadata['mainGameItem']['releaseInfo'][0]['appId']
+ base_game = self.core.get_game(app_name)
+ # check if base_game is actually installed
+ if not self.core.is_installed(app_name):
+ # download mode doesn't care about whether something's installed
+ if not args.no_install:
+ logger.fatal(f'Base game "{app_name}" is not installed!')
+ return
+ else:
+ base_game = None
+
+ if args.repair_mode:
+ if not self.core.is_installed(game.app_name):
+ logger.error(f'Game "{game.app_title}" ({game.app_name}) is not installed!')
+ return
+
+ if not os.path.exists(repair_file):
+ logger.info('Game has not been verified yet.')
+ # Rare: we do not want to verify while preparing the download in the InstallDialog
+ # Rare: we handle it differently through the GameInfo tab
+ logger.error('Game has not been verified yet.')
+ return
+ else:
+ logger.info(f'Using existing repair file: {repair_file}')
+
+ # check if SDL should be disabled
+ sdl_enabled = not args.install_tag and not game.is_dlc
+ config_tags = self.core.lgd.config.get(game.app_name, 'install_tags', fallback=None)
+ config_disable_sdl = self.core.lgd.config.getboolean(game.app_name, 'disable_sdl', fallback=False)
+ # remove config flag if SDL is reset
+ if config_disable_sdl and args.reset_sdl and not args.disable_sdl:
+ self.core.lgd.config.remove_option(game.app_name, 'disable_sdl')
+ # if config flag is not yet set, set it and remove previous install tags
+ elif not config_disable_sdl and args.disable_sdl:
+ logger.info('Clearing install tags from config and disabling SDL for title.')
+ if config_tags:
+ self.core.lgd.config.remove_option(game.app_name, 'install_tags')
+ config_tags = None
+ self.core.lgd.config.set(game.app_name, 'disable_sdl', True)
+ sdl_enabled = False
+ # just disable SDL, but keep config tags that have been manually specified
+ elif config_disable_sdl or args.disable_sdl:
+ sdl_enabled = False
+
+ if sdl_enabled and ((sdl_name := get_sdl_appname(game.app_name)) is not None):
+ if not self.core.is_installed(game.app_name) or config_tags is None or args.reset_sdl:
+ sdl_data = self.core.get_sdl_data(sdl_name, platform=args.platform)
+ if sdl_data:
+ if args.skip_sdl:
+ args.install_tag = ['']
+ if '__required' in sdl_data:
+ args.install_tag.extend(sdl_data['__required']['tags'])
+ else:
+ args.install_tag = sdl_prompt(sdl_data, game.app_title)
+ self.core.lgd.config.set(game.app_name, 'install_tags', ','.join(args.install_tag))
+ else:
+ logger.error(f'Unable to get SDL data for {sdl_name}')
+ else:
+ args.install_tag = config_tags.split(',')
+ elif args.install_tag and not game.is_dlc and not args.no_install:
+ config_tags = ','.join(args.install_tag)
+ logger.info(f'Saving install tags for "{game.app_name}" to config: {config_tags}')
+ self.core.lgd.config.set(game.app_name, 'install_tags', config_tags)
+ elif not game.is_dlc:
+ if config_tags and args.reset_sdl:
+ logger.info('Clearing install tags from config.')
+ self.core.lgd.config.remove_option(game.app_name, 'install_tags')
+ elif config_tags:
+ logger.info(f'Using install tags from config: {config_tags}')
+ args.install_tag = config_tags.split(',')
+
+ logger.info(f'Preparing download for "{game.app_title}" ({game.app_name})...')
+ # todo use status queue to print progress from CLI
+ # This has become a little ridiculous hasn't it?
+ dlm, analysis, igame = self.core.prepare_download(game=game, base_game=base_game, base_path=args.base_path,
+ force=args.force, max_shm=args.shared_memory,
+ max_workers=args.max_workers, game_folder=args.game_folder,
+ disable_patching=args.disable_patching,
+ override_manifest=args.override_manifest,
+ override_old_manifest=args.override_old_manifest,
+ override_base_url=args.override_base_url,
+ platform=args.platform,
+ file_prefix_filter=args.file_prefix,
+ file_exclude_filter=args.file_exclude_prefix,
+ file_install_tag=args.install_tag,
+ dl_optimizations=args.order_opt,
+ dl_timeout=args.dl_timeout,
+ repair=args.repair_mode,
+ repair_use_latest=args.repair_and_update,
+ disable_delta=args.disable_delta,
+ override_delta_manifest=args.override_delta_manifest,
+ preferred_cdn=args.preferred_cdn,
+ disable_https=args.disable_https)
+
+ # game is either up-to-date or hasn't changed, so we have nothing to do
+ if not analysis.dl_size:
+ logger.info('Download size is 0, the game is either already up to date or has not changed. Exiting...')
+ self.install_game_cleanup(game, igame, args.repair_mode, repair_file)
+ return
+
+ res = self.core.check_installation_conditions(analysis=analysis, install=igame, game=game,
+ updating=self.core.is_installed(args.app_name),
+ ignore_space_req=args.ignore_space)
+
+ return dlm, analysis, igame, game, args.repair_mode, repair_file, res
+
+ # Rare: This is currently handled in DownloadThread, this is a trial
+ def install_game_real(self, args: LgndrInstallGameRealArgs, dlm: DLManager, game: Game, igame: InstalledGame) -> LgndrInstallGameRealRet:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger)
+ ret = LgndrInstallGameRealRet(game.app_name)
+
+ start_t = time.time()
+
+ try:
+ # set up logging stuff (should be moved somewhere else later)
+ dlm.logging_queue = self.logging_queue
+ dlm.proc_debug = args.dlm_debug
+
+ dlm.start()
+ while dlm.is_alive():
+ try:
+ args.ui_update(dlm.status_queue.get(timeout=1.0))
+ except queue.Empty:
+ pass
+ if args.dlm_signals.update:
+ try:
+ dlm.signals_queue.put(args.dlm_signals, block=False, timeout=1.0)
+ except queue.Full:
+ pass
+ time.sleep(dlm.update_interval / 10)
+ dlm.join()
+ except Exception as e:
+ end_t = time.time()
+ logger.info(f'Installation failed after {end_t - start_t:.02f} seconds.')
+ logger.warning(f'The following exception occurred while waiting for the downloader to finish: {e!r}. '
+ f'Try restarting the process, the resume file will be used to start where it failed. '
+ f'If it continues to fail please open an issue on GitHub.')
+ ret.ret_code = ret.ReturnCode.ERROR
+ ret.message = f"{e!r}"
+ return ret
+ else:
+ end_t = time.time()
+ if args.dlm_signals.kill is True:
+ logger.info(f"Download stopped after {end_t - start_t:.02f} seconds.")
+ ret.exit_code = ret.ReturnCode.STOPPED
+ return ret
+ logger.info(f"Download finished in {end_t - start_t:.02f} seconds.")
+ if not args.no_install:
+ # Allow setting savegame directory at install time so sync-saves will work immediately
+ if (game.supports_cloud_saves or game.supports_mac_cloud_saves) and args.save_path:
+ igame.save_path = args.save_path
+
+ postinstall = self.core.install_game(igame)
+ if postinstall:
+ self._handle_postinstall(postinstall, igame, yes=args.yes, choice=args.install_preqs)
+
+ dlcs = self.core.get_dlc_for_game(game.app_name)
+ if dlcs and not args.skip_dlcs:
+ for dlc in dlcs:
+ ret.dlcs.append(
+ {
+ "app_name": dlc.app_name,
+ "app_title": dlc.app_title,
+ "app_version": dlc.app_version(args.platform)
+ }
+ )
+
+ # Rare: We do not install DLCs automatically, we offer to do so through our downloads tab
+
+ if (game.supports_cloud_saves or game.supports_mac_cloud_saves) and not game.is_dlc:
+ # todo option to automatically download saves after the installation
+ # args does not have the required attributes for sync_saves in here,
+ # not sure how to solve that elegantly.
+ logger.info(f'This game supports cloud saves, syncing is handled by the "sync-saves" command. '
+ f'To download saves for this game run "legendary sync-saves {args.app_name}"')
+ ret.sync_saves = True
+
+ # show tip again after installation finishes so users hopefully actually see it
+ if tip_url := self.core.get_game_tip(igame.app_name):
+ ret.tip_url = tip_url
+
+ self.install_game_cleanup(game, igame, args.repair_mode, args.repair_file)
+
+ logger.info(f'Finished installation process in {end_t - start_t:.02f} seconds.')
+
+ return ret
+
+ def install_game_cleanup(self, game: Game, igame: InstalledGame, repair_mode: bool = False, repair_file: str = '') -> None:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(LgndrIndirectStatus(), self.logger)
+
+ old_igame = self.core.get_installed_game(game.app_name)
+ if old_igame and repair_mode and os.path.exists(repair_file):
+ if old_igame.needs_verification:
+ old_igame.needs_verification = False
+ self.core.install_game(old_igame)
+
+ logger.debug('Removing repair file.')
+ os.remove(repair_file)
+
+ # check if install tags have changed, if they did; try deleting files that are no longer required.
+ if old_igame and old_igame.install_tags != igame.install_tags:
+ old_igame.install_tags = igame.install_tags
+ logger.info('Deleting now untagged files.')
+ self.core.uninstall_tag(old_igame)
+ self.core.install_game(old_igame)
+
+ def _handle_postinstall(self, postinstall, igame, yes=False, choice=True):
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(LgndrIndirectStatus(), self.logger)
+ # noinspection PyShadowingBuiltins
+ def print(x): self.logger.info(x) if x else None
+ # noinspection PyShadowingBuiltins
+ def input(x): return 'y' if choice else 'i'
+
+ print('\nThis game lists the following prequisites to be installed:')
+ print(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}')
+ print('')
+
+ if os.name == 'nt':
+ if yes:
+ c = 'n' # we don't want to launch anything, just silent install.
+ else:
+ choice = input('Do you wish to install the prerequisites? ([y]es, [n]o, [i]gnore): ')
+ c = choice.lower()[0]
+ print('')
+
+ if c == 'i': # just set it to installed
+ logger.info('Marking prerequisites as installed...')
+ self.core.prereq_installed(igame.app_name)
+ elif c == 'y': # set to installed and launch installation
+ logger.info('Launching prerequisite executable..')
+ self.core.prereq_installed(igame.app_name)
+ req_path, req_exec = os.path.split(postinstall['path'])
+ work_dir = os.path.join(igame.install_path, req_path)
+ fullpath = os.path.join(work_dir, req_exec)
+ try:
+ p = subprocess.Popen([fullpath, postinstall['args']], cwd=work_dir, shell=True)
+ p.wait()
+ except Exception as e:
+ logger.error(f'Failed to run prereq executable with: {e!r}')
+ else:
+ logger.info('Automatic installation not available on Linux.')
+
+ def uninstall_game(self, args: LgndrUninstallGameArgs) -> None:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger, logging.WARNING)
+ get_boolean_choice = args.get_boolean_choice
+
+ args.app_name = self._resolve_aliases(args.app_name)
+ igame = self.core.get_installed_game(args.app_name)
+ if not igame:
+ logger.error(f'Game {args.app_name} not installed, cannot uninstall!')
+ return
+
+ if not args.yes:
+ if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False):
+ return
+
+ try:
+ if not igame.is_dlc:
+ # Remove DLC first so directory is empty when game uninstall runs
+ dlcs = self.core.get_dlc_for_game(igame.app_name)
+ for dlc in dlcs:
+ if (idlc := self.core.get_installed_game(dlc.app_name)) is not None:
+ logger.info(f'Uninstalling DLC "{dlc.app_name}"...')
+ self.core.uninstall_game(idlc, delete_files=not args.keep_files)
+
+ logger.info(f'Removing "{igame.title}" from "{igame.install_path}"...')
+ self.core.uninstall_game(igame, delete_files=not args.keep_files,
+ delete_root_directory=not igame.is_dlc)
+ logger.info('Game has been uninstalled.')
+ return
+ except Exception as e:
+ logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.')
+ return
+
+ def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False) -> Optional[Tuple[int, int]]:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger)
+
+ args.app_name = self._resolve_aliases(args.app_name)
+ if not self.core.is_installed(args.app_name):
+ logger.error(f'Game "{args.app_name}" is not installed')
+ return
+
+ logger.info(f'Loading installed manifest for "{args.app_name}"')
+ igame = self.core.get_installed_game(args.app_name)
+ if not os.path.exists(igame.install_path):
+ logger.error(f'Install path "{igame.install_path}" does not exist, make sure all necessary mounts '
+ f'are available. If you previously deleted the game folder without uninstalling, run '
+ f'"legendary uninstall -y {igame.app_name}" and reinstall from scratch.')
+ return
+
+ manifest_data, _ = self.core.get_installed_manifest(args.app_name)
+ if manifest_data is None:
+ if repair_mode:
+ if not repair_online:
+ logger.critical('No manifest could be loaded, the manifest file may be missing!')
+ raise ValueError('Local manifest is missing')
+
+ logger.warning('No manifest could be loaded, the file may be missing. Downloading the latest manifest.')
+ game = self.core.get_game(args.app_name, platform=igame.platform)
+ manifest_data, _ = self.core.get_cdn_manifest(game, igame.platform)
+ # Rare: Save the manifest if we downloaded it because it was missing
+ self.core.lgd.save_manifest(game.app_name, manifest_data,
+ version=self.core.load_manifest(manifest_data).meta.build_version,
+ platform=igame.platform)
+ else:
+ logger.critical(f'Manifest appears to be missing! To repair, run "legendary repair '
+ f'{args.app_name} --repair-and-update", this will however redownload all files '
+ f'that do not match the latest manifest in their entirety.')
+ return
+
+ manifest = self.core.load_manifest(manifest_data)
+
+ files = sorted(manifest.file_manifest_list.elements,
+ key=lambda a: a.filename.lower())
+
+ # build list of hashes
+ if config_tags := self.core.lgd.config.get(args.app_name, 'install_tags', fallback=None):
+ install_tags = set(i.strip() for i in config_tags.split(','))
+ file_list = [
+ (f.filename, f.sha_hash.hex())
+ for f in files
+ if any(it in install_tags for it in f.install_tags) or not f.install_tags
+ ]
+ else:
+ file_list = [(f.filename, f.sha_hash.hex()) for f in files]
+
+ total = len(file_list)
+ total_size = sum(manifest.file_manifest_list.get_file_by_path(fm[0]).file_size
+ for fm in file_list)
+ num = processed = last_processed = 0
+ speed = 0.0
+ percentage = 0.0
+ failed = []
+ missing = []
+
+ last_update = time.time()
+
+ logger.info(f'Verifying "{igame.title}" version "{manifest.meta.build_version}"')
+ repair_file = []
+ for result, path, result_hash, bytes_read in validate_files(igame.install_path, file_list):
+ processed += bytes_read
+ percentage = (processed / total_size) * 100.0
+ num += 1
+
+ if (delta := ((current_time := time.time()) - last_update)) > 1 or (not last_processed and delta > 1):
+ last_update = current_time
+ speed = (processed - last_processed) / 1024 / 1024 / delta
+ last_processed = processed
+
+ if args.verify_stdout:
+ args.verify_stdout(num, total, percentage, speed)
+
+ if result == VerifyResult.HASH_MATCH:
+ repair_file.append(f'{result_hash}:{path}')
+ continue
+ elif result == VerifyResult.HASH_MISMATCH:
+ logger.error(f'File does not match hash: "{path}"')
+ repair_file.append(f'{result_hash}:{path}')
+ failed.append(path)
+ elif result == VerifyResult.FILE_MISSING:
+ logger.error(f'File is missing: "{path}"')
+ missing.append(path)
+ else:
+ logger.error(f'Other failure (see log), treating file as missing: "{path}"')
+ missing.append(path)
+
+ if args.verify_stdout:
+ args.verify_stdout(num, total, percentage, speed)
+
+ # always write repair file, even if all match
+ if repair_file:
+ repair_filename = os.path.join(self.core.lgd.get_tmp_path(), f'{args.app_name}.repair')
+ with open(repair_filename, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(repair_file))
+ logger.debug(f'Written repair file to "{repair_filename}"')
+
+ if not missing and not failed:
+ logger.info('Verification finished successfully.')
+ return 0, 0
+ else:
+ logger.error(f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.')
+ if print_command:
+ logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.')
+ return len(failed), len(missing)
+
+ def import_game(self, args: LgndrImportGameArgs) -> None:
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger)
+ get_boolean_choice = args.get_boolean_choice
+
+ # make sure path is absolute
+ args.app_path = os.path.abspath(args.app_path)
+ args.app_name = self._resolve_aliases(args.app_name)
+
+ if not os.path.exists(args.app_path):
+ logger.error(f'Specified path "{args.app_path}" does not exist!')
+ return
+
+ if self.core.is_installed(args.app_name):
+ logger.error('Game is already installed!')
+ return
+
+ if not self.core.login():
+ logger.error('Log in failed!')
+ return
+
+ # do some basic checks
+ game = self.core.get_game(args.app_name, update_meta=True, platform=args.platform)
+ if not game:
+ logger.fatal(f'Did not find game "{args.app_name}" on account.')
+ return
+
+ if game.is_dlc:
+ release_info = game.metadata.get('mainGameItem', {}).get('releaseInfo')
+ if release_info:
+ main_game_appname = release_info[0]['appId']
+ main_game_title = game.metadata['mainGameItem']['title']
+ if not self.core.is_installed(main_game_appname):
+ logger.error(f'Import candidate is DLC but base game "{main_game_title}" '
+ f'(App name: "{main_game_appname}") is not installed!')
+ return
+ else:
+ logger.fatal(f'Unable to get base game information for DLC, cannot continue.')
+ return
+
+ # 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('/'))
+ # 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))
+ for f in manifest.file_manifest_list.elements)
+ ratio = found / total
+
+ if not found:
+ logger.error(f'No files belonging to {"DLC" if game.is_dlc else "Game"} "{game.app_title}" '
+ f'({game.app_name}) found in the specified location, please verify that the path is correct.')
+ if not game.is_dlc:
+ # check if game folder is in path, suggest alternative
+ folder = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', game.app_name)
+ if folder and folder not in args.app_path:
+ new_path = os.path.join(args.app_path, folder)
+ logger.info(f'Did you mean "{new_path}"?')
+ return
+
+ if not game.is_dlc and not os.path.exists(exe_path) and not args.disable_check:
+ logger.error(f'Game executable could not be found at "{exe_path}", '
+ f'please verify that the specified path is correct.')
+ return
+
+ if ratio < 0.95:
+ logger.warning('Some files are missing from the game installation, install may not '
+ 'match latest Epic Games Store version or might be corrupted.')
+ else:
+ logger.info(f'{"DLC" if game.is_dlc else "Game"} install appears to be complete.')
+
+ self.core.install_game(igame)
+ if igame.needs_verification:
+ logger.info(f'NOTE: The {"DLC" if game.is_dlc else "Game"} installation will have to be '
+ f'verified before it can be updated with legendary.')
+ logger.info(f'Run "legendary repair {args.app_name}" to do so.')
+ else:
+ logger.info(f'Installation had Epic Games Launcher metadata for version "{igame.version}", '
+ f'verification will not be required.')
+
+ # check for importable DLC
+ if not args.skip_dlcs:
+ dlcs = self.core.get_dlc_for_game(game.app_name)
+ if dlcs:
+ logger.info(f'Found {len(dlcs)} items of DLC that could be imported.')
+ import_dlc = True
+ if not args.yes and not args.with_dlcs:
+ if not get_boolean_choice(f'Do you wish to automatically attempt to import all DLCs?'):
+ import_dlc = False
+
+ if import_dlc:
+ for dlc in dlcs:
+ args.app_name = dlc.app_name
+ self.import_game(args)
+
+ logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.')
+ return
+
+ def move(self, args):
+ # Override logger for the local context to use message as part of the indirect return value
+ logger = LgndrIndirectLogger(args.indirect_status, self.logger)
+
+ app_name = self._resolve_aliases(args.app_name)
+ igame = self.core.get_installed_game(app_name, skip_sync=True)
+ if not igame:
+ logger.error(f'No installed game found for "{app_name}"')
+ return
+
+ old_base, game_folder = os.path.split(igame.install_path.replace('\\', '/'))
+ new_path = os.path.join(args.new_path, game_folder)
+ logger.info(f'Moving "{game_folder}" from "{old_base}" to "{args.new_path}"')
+
+ if not args.skip_move:
+ try:
+ if not os.path.exists(args.new_path):
+ os.makedirs(args.new_path)
+
+ os.rename(igame.install_path, new_path)
+ except Exception as e:
+ if isinstance(e, OSError) and e.errno == 18:
+ logger.error(f'Moving to a different drive is not supported. Move the folder manually to '
+ f'"{new_path}" and run "legendary move {app_name} "{args.new_path}" --skip-move"')
+ elif isinstance(e, FileExistsError):
+ logger.error(f'The target path already contains a folder called "{game_folder}", '
+ f'please remove or rename it first.')
+ else:
+ logger.error(f'Moving failed with unknown error {e!r}.')
+ logger.info(f'Try moving the folder manually to "{new_path}" and running '
+ f'"legendary move {app_name} "{args.new_path}" --skip-move"')
+ return
+ else:
+ logger.info(f'Not moving, just rewriting legendary metadata...')
+
+ igame.install_path = new_path
+ self.core.install_game(igame)
+ logger.info('Finished.')
+
+# fmt: on
diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py
new file mode 100644
index 00000000..2055697b
--- /dev/null
+++ b/rare/lgndr/core.py
@@ -0,0 +1,97 @@
+from multiprocessing import Queue
+
+from legendary.core import LegendaryCore as LegendaryCoreReal
+from legendary.models.downloading import AnalysisResult
+from legendary.models.game import Game, InstalledGame
+from legendary.models.manifest import ManifestMeta
+
+from .api_exception import LgndrException, LgndrCoreLogHandler
+from .manager import DLManager
+
+# import legendary.core
+# legendary.core.DLManager = DLManager
+
+
+# fmt: off
+class LegendaryCore(LegendaryCoreReal):
+
+ def __init__(self, override_config=None, timeout=10.0):
+ super(LegendaryCore, self).__init__(override_config=override_config, timeout=timeout)
+ self.handler = LgndrCoreLogHandler()
+ self.log.addHandler(self.handler)
+
+ # skip_sync defaults to false but since Rare is persistent, skip by default
+ # def get_installed_game(self, app_name, skip_sync=True) -> InstalledGame:
+ # return super(LegendaryCore, self).get_installed_game(app_name, skip_sync)
+
+ def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '',
+ status_q: Queue = None, max_shm: int = 0, max_workers: int = 0,
+ force: bool = False, disable_patching: bool = False,
+ game_folder: str = '', override_manifest: str = '',
+ override_old_manifest: str = '', override_base_url: str = '',
+ platform: str = 'Windows', file_prefix_filter: list = None,
+ file_exclude_filter: list = None, file_install_tag: list = None,
+ dl_optimizations: bool = False, dl_timeout: int = 10,
+ repair: bool = False, repair_use_latest: bool = False,
+ disable_delta: bool = False, override_delta_manifest: str = '',
+ egl_guid: str = '', preferred_cdn: str = None,
+ disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta):
+ dlm, analysis, igame = super(LegendaryCore, self).prepare_download(
+ game=game, base_game=base_game, base_path=base_path,
+ status_q=status_q, max_shm=max_shm, max_workers=max_workers,
+ force=force, disable_patching=disable_patching,
+ game_folder=game_folder, override_manifest=override_manifest,
+ override_old_manifest=override_old_manifest, override_base_url=override_base_url,
+ platform=platform, file_prefix_filter=file_prefix_filter,
+ file_exclude_filter=file_exclude_filter, file_install_tag=file_install_tag,
+ dl_optimizations=dl_optimizations, dl_timeout=dl_timeout,
+ repair=repair, repair_use_latest=repair_use_latest,
+ disable_delta=disable_delta, override_delta_manifest=override_delta_manifest,
+ egl_guid=egl_guid, preferred_cdn=preferred_cdn,
+ disable_https=disable_https
+ )
+ # lk: monkeypatch run_real (the method that emits the stats) into DLManager
+ dlm.run_real = DLManager.run_real.__get__(dlm, DLManager)
+ # lk: set the queue for reporting statistics back the UI
+ dlm.status_queue = Queue()
+ # lk: set the queue to send control signals to the DLManager
+ # lk: this doesn't exist in the original class, but it is monkeypatched in
+ dlm.signals_queue = Queue()
+ return dlm, analysis, igame
+
+ def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delete_root_directory=False):
+ try:
+ super(LegendaryCore, self).uninstall_game(installed_game, delete_files, delete_root_directory)
+ except Exception as e:
+ raise e
+ finally:
+ pass
+
+ def egl_import(self, app_name):
+ try:
+ super(LegendaryCore, self).egl_import(app_name)
+ except LgndrException as ret:
+ raise ret
+ finally:
+ pass
+
+ def egl_export(self, app_name):
+ try:
+ super(LegendaryCore, self).egl_export(app_name)
+ except LgndrException as ret:
+ raise ret
+ finally:
+ pass
+
+ def prepare_overlay_install(self, path=None):
+ dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path)
+ # lk: monkeypatch status_q (the queue for download stats)
+ dlm.run_real = DLManager.run_real.__get__(dlm, DLManager)
+ # lk: set the queue for reporting statistics back the UI
+ dlm.status_queue = Queue()
+ # lk: set the queue to send control signals to the DLManager
+ # lk: this doesn't exist in the original class, but it is monkeypatched in
+ dlm.signals_queue = Queue()
+ return dlm, analysis_result, igame
+
+# fmt: on
diff --git a/rare/lgndr/downloading.py b/rare/lgndr/downloading.py
new file mode 100644
index 00000000..2b5dc37b
--- /dev/null
+++ b/rare/lgndr/downloading.py
@@ -0,0 +1,25 @@
+from dataclasses import dataclass
+from typing import Optional
+
+
+@dataclass
+class UIUpdate:
+ """
+ Status update object sent from the manager to the CLI/GUI to update status indicators
+ Inheritance doesn't work due to optional arguments in UIUpdate proper
+ """
+ progress: float
+ download_speed: float
+ write_speed: float
+ read_speed: float
+ memory_usage: float
+ runtime: float
+ estimated_time_left: float
+ processed_chunks: int
+ chunk_tasks: int
+ total_downloaded: float
+ total_written: float
+ cache_usage: float
+ active_tasks: int
+ download_compressed_speed: float
+ current_filename: Optional[str] = None
diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py
new file mode 100644
index 00000000..b9eb32d2
--- /dev/null
+++ b/rare/lgndr/manager.py
@@ -0,0 +1,229 @@
+import logging
+import os
+import queue
+import time
+from multiprocessing import Queue as MPQueue
+from multiprocessing.shared_memory import SharedMemory
+from sys import exit
+from threading import Condition, Thread
+
+from legendary.downloader.mp.manager import DLManager as DLManagerReal
+from legendary.downloader.mp.workers import DLWorker, FileWorker
+from legendary.models.downloading import ChunkTask, SharedMemorySegment, TerminateWorkerTask
+
+from .downloading import UIUpdate
+from .api_monkeys import DLManagerSignals
+
+
+# fmt: off
+class DLManager(DLManagerReal):
+ # Rare: prototype to avoid undefined variable in type checkers
+ signals_queue: MPQueue
+
+ # @staticmethod
+ def run_real(self):
+ self.shared_memory = SharedMemory(create=True, size=self.max_shared_memory)
+ self.log.debug(f'Created shared memory of size: {self.shared_memory.size / 1024 / 1024:.02f} MiB')
+
+ # create the shared memory segments and add them to their respective pools
+ for i in range(int(self.shared_memory.size / self.analysis.biggest_chunk)):
+ _sms = SharedMemorySegment(offset=i * self.analysis.biggest_chunk,
+ end=i * self.analysis.biggest_chunk + self.analysis.biggest_chunk)
+ self.sms.append(_sms)
+
+ self.log.debug(f'Created {len(self.sms)} shared memory segments.')
+
+ # Create queues
+ self.dl_worker_queue = MPQueue(-1)
+ self.writer_queue = MPQueue(-1)
+ self.dl_result_q = MPQueue(-1)
+ self.writer_result_q = MPQueue(-1)
+
+ self.log.info(f'Starting download workers...')
+ for i in range(self.max_workers):
+ w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q,
+ self.shared_memory.name, logging_queue=self.logging_queue,
+ dl_timeout=self.dl_timeout)
+ self.children.append(w)
+ w.start()
+
+ self.log.info('Starting file writing worker...')
+ writer_p = FileWorker(self.writer_queue, self.writer_result_q, self.dl_dir,
+ self.shared_memory.name, self.cache_dir, self.logging_queue)
+ self.children.append(writer_p)
+ writer_p.start()
+
+ num_chunk_tasks = sum(isinstance(t, ChunkTask) for t in self.tasks)
+ num_dl_tasks = len(self.chunks_to_dl)
+ num_tasks = len(self.tasks)
+ num_shared_memory_segments = len(self.sms)
+ self.log.debug(f'Chunks to download: {num_dl_tasks}, File tasks: {num_tasks}, Chunk tasks: {num_chunk_tasks}')
+
+ # active downloader tasks
+ self.active_tasks = 0
+ processed_chunks = 0
+ processed_tasks = 0
+ total_dl = 0
+ total_write = 0
+
+ # synchronization conditions
+ shm_cond = Condition()
+ task_cond = Condition()
+ self.conditions = [shm_cond, task_cond]
+
+ # start threads
+ s_time = time.time()
+ 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,)))
+
+ for t in self.threads:
+ t.start()
+
+ last_update = time.time()
+
+ # Rare: kill requested
+ kill_request = False
+
+ while processed_tasks < num_tasks:
+ delta = time.time() - last_update
+ if not delta:
+ time.sleep(self.update_interval)
+ continue
+
+ # update all the things
+ processed_chunks += self.num_processed_since_last
+ processed_tasks += self.num_tasks_processed_since_last
+
+ total_dl += self.bytes_downloaded_since_last
+ total_write += self.bytes_written_since_last
+
+ dl_speed = self.bytes_downloaded_since_last / delta
+ dl_unc_speed = self.bytes_decompressed_since_last / delta
+ w_speed = self.bytes_written_since_last / delta
+ r_speed = self.bytes_read_since_last / delta
+ # c_speed = self.num_processed_since_last / delta
+
+ # set temporary counters to 0
+ 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()
+
+ perc = (processed_chunks / num_chunk_tasks) * 100
+ runtime = time.time() - s_time
+ total_avail = len(self.sms)
+ total_used = (num_shared_memory_segments - total_avail) * (self.analysis.biggest_chunk / 1024 / 1024)
+
+ if runtime and processed_chunks:
+ average_speed = processed_chunks / runtime
+ estimate = (num_chunk_tasks - processed_chunks) / average_speed
+ hours, estimate = int(estimate // 3600), estimate % 3600
+ minutes, seconds = int(estimate // 60), int(estimate % 60)
+
+ rt_hours, runtime = int(runtime // 3600), runtime % 3600
+ rt_minutes, rt_seconds = int(runtime // 60), int(runtime % 60)
+ else:
+ estimate = 0
+ hours = minutes = seconds = 0
+ rt_hours = rt_minutes = rt_seconds = 0
+
+ # Rare: Disable up to INFO logging level for the segment below
+ log_level = self.log.level
+ self.log.setLevel(logging.ERROR)
+ self.log.info(f'= Progress: {perc:.02f}% ({processed_chunks}/{num_chunk_tasks}), '
+ f'Running for {rt_hours:02d}:{rt_minutes:02d}:{rt_seconds:02d}, '
+ f'ETA: {hours:02d}:{minutes:02d}:{seconds:02d}')
+ self.log.info(f' - Downloaded: {total_dl / 1024 / 1024:.02f} MiB, '
+ f'Written: {total_write / 1024 / 1024:.02f} MiB')
+ self.log.info(f' - Cache usage: {total_used:.02f} MiB, active tasks: {self.active_tasks}')
+ self.log.info(f' + Download\t- {dl_speed / 1024 / 1024:.02f} MiB/s (raw) '
+ f'/ {dl_unc_speed / 1024 / 1024:.02f} MiB/s (decompressed)')
+ self.log.info(f' + Disk\t- {w_speed / 1024 / 1024:.02f} MiB/s (write) / '
+ f'{r_speed / 1024 / 1024:.02f} MiB/s (read)')
+ # Rare: Restore previous logging level
+ self.log.setLevel(log_level)
+
+ # send status update to back to instantiator (if queue exists)
+ if self.status_queue:
+ try:
+ self.status_queue.put(UIUpdate(
+ progress=perc, download_speed=dl_unc_speed, write_speed=w_speed, read_speed=r_speed,
+ runtime=round(runtime),
+ estimated_time_left=round(estimate),
+ processed_chunks=processed_chunks,
+ chunk_tasks=num_chunk_tasks,
+ total_downloaded=total_dl,
+ total_written=total_write,
+ cache_usage=total_used,
+ active_tasks=self.active_tasks,
+ download_compressed_speed=dl_speed,
+ memory_usage=total_used * 1024 * 1024
+ ), timeout=1.0)
+ except Exception as e:
+ self.log.warning(f'Failed to send status update to queue: {e!r}')
+
+ # Rare: queue of control signals
+ try:
+ signals: DLManagerSignals = self.signals_queue.get(timeout=0.5)
+ self.log.warning('Immediate stop requested!')
+ if signals.kill is True:
+ # lk: graceful but not what legendary does
+ self.running = False
+ # send conditions to unlock threads if they aren't already
+ for cond in self.conditions:
+ with cond:
+ cond.notify()
+ kill_request = True
+ break
+ # # lk: alternative way, but doesn't clean shm
+ # for i in range(self.max_workers):
+ # self.dl_worker_queue.put_nowait(TerminateWorkerTask())
+ #
+ # self.log.info('Waiting for installation to finish...')
+ # self.writer_queue.put_nowait(TerminateWorkerTask())
+ # raise KeyboardInterrupt
+ except queue.Empty:
+ pass
+
+ time.sleep(self.update_interval)
+
+ for i in range(self.max_workers):
+ self.dl_worker_queue.put_nowait(TerminateWorkerTask())
+
+ self.log.info('Waiting for installation to finish...')
+ self.writer_queue.put_nowait(TerminateWorkerTask())
+
+ writer_p.join(timeout=10.0)
+ if writer_p.exitcode is None:
+ self.log.warning(f'Terminating writer process, no exit code!')
+ writer_p.terminate()
+
+ # forcibly kill DL workers that are not actually dead yet
+ for child in self.children:
+ if child.exitcode is None:
+ child.terminate()
+
+ # make sure all the threads are dead.
+ for t in self.threads:
+ t.join(timeout=5.0)
+ if t.is_alive():
+ self.log.warning(f'Thread did not terminate! {repr(t)}')
+
+ # clean up resume file
+ if self.resume_file and not kill_request:
+ try:
+ os.remove(self.resume_file)
+ except OSError as e:
+ self.log.warning(f'Failed to remove resume file: {e!r}')
+
+ # close up shared memory
+ self.shared_memory.close()
+ self.shared_memory.unlink()
+ self.shared_memory = None
+
+ self.log.info('All done! Download manager quitting...')
+ # finally, exit the process.
+ exit(0)
+
+# fmt: on
diff --git a/rare/models/install.py b/rare/models/install.py
new file mode 100644
index 00000000..b4dedef8
--- /dev/null
+++ b/rare/models/install.py
@@ -0,0 +1,65 @@
+import os
+import platform as pf
+from dataclasses import dataclass
+from typing import List, Optional, Callable, Dict
+
+from legendary.models.downloading import AnalysisResult, ConditionCheckResult
+from legendary.models.game import Game, InstalledGame
+
+from rare.lgndr.manager import DLManager
+
+
+@dataclass
+class InstallOptionsModel:
+ app_name: str
+ base_path: str = ""
+ shared_memory: int = 1024
+ max_workers: int = os.cpu_count() * 2
+ force: bool = False
+ platform: str = "Windows"
+ install_tag: Optional[List[str]] = None
+ order_opt: bool = False
+ repair_mode: bool = False
+ repair_and_update: bool = False
+ no_install: bool = False
+ ignore_space: bool = False
+ skip_dlcs: bool = False
+ with_dlcs: bool = False
+ # Rare's internal arguments
+ # FIXME: Do we really need all of these?
+ create_shortcut: bool = True
+ overlay: bool = False
+ update: bool = False
+ silent: bool = False
+ install_preqs: bool = pf.system() == "Windows"
+
+ def __post_init__(self):
+ self.sdl_prompt: Callable[[str, str], list] = \
+ lambda app_name, title: self.install_tag if self.install_tag is not None else [""]
+
+ def as_install_kwargs(self) -> Dict:
+ return {
+ k: getattr(self, k)
+ for k in self.__dict__
+ if k not in ["update", "silent", "create_shortcut", "overlay", "install_preqs"]
+ }
+
+
+@dataclass
+class InstallDownloadModel:
+ dlm: DLManager
+ analysis: AnalysisResult
+ igame: InstalledGame
+ game: Game
+ repair: bool
+ repair_file: str
+ res: ConditionCheckResult
+
+
+@dataclass
+class InstallQueueItemModel:
+ download: Optional[InstallDownloadModel] = None
+ options: Optional[InstallOptionsModel] = None
+
+ def __bool__(self):
+ return (self.download is not None) and (self.options is not None)
diff --git a/rare/models/signals.py b/rare/models/signals.py
index 97119b09..7ae32cdb 100644
--- a/rare/models/signals.py
+++ b/rare/models/signals.py
@@ -1,6 +1,6 @@
from PyQt5.QtCore import QObject, pyqtSignal
-from rare.utils.models import InstallOptionsModel
+from .install import InstallOptionsModel
class GlobalSignals(QObject):
diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py
index c0fff881..6bbbf05a 100644
--- a/rare/shared/__init__.py
+++ b/rare/shared/__init__.py
@@ -8,7 +8,7 @@ and only ONCE!
from argparse import Namespace
from typing import Optional
-from legendary.core import LegendaryCore
+from rare.lgndr.core import LegendaryCore
from rare.models.apiresults import ApiResults
from rare.models.signals import GlobalSignals
diff --git a/rare/ui/components/tabs/games/import_sync/import_group.py b/rare/ui/components/tabs/games/import_sync/import_group.py
index a7b7bc1d..91289ce5 100644
--- a/rare/ui/components/tabs/games/import_sync/import_group.py
+++ b/rare/ui/components/tabs/games/import_sync/import_group.py
@@ -14,10 +14,11 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_ImportGroup(object):
def setupUi(self, ImportGroup):
ImportGroup.setObjectName("ImportGroup")
- ImportGroup.resize(501, 136)
+ ImportGroup.resize(501, 162)
ImportGroup.setWindowTitle("ImportGroup")
ImportGroup.setWindowFilePath("")
self.formLayout = QtWidgets.QFormLayout(ImportGroup)
+ self.formLayout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter)
self.formLayout.setObjectName("formLayout")
self.path_edit_label = QtWidgets.QLabel(ImportGroup)
self.path_edit_label.setObjectName("path_edit_label")
@@ -40,6 +41,15 @@ class Ui_ImportGroup(object):
self.import_folder_check.setFont(font)
self.import_folder_check.setObjectName("import_folder_check")
self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.import_folder_check)
+ self.import_dlcs_label = QtWidgets.QLabel(ImportGroup)
+ self.import_dlcs_label.setObjectName("import_dlcs_label")
+ self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.import_dlcs_label)
+ self.import_dlcs_check = QtWidgets.QCheckBox(ImportGroup)
+ font = QtGui.QFont()
+ font.setItalic(True)
+ self.import_dlcs_check.setFont(font)
+ self.import_dlcs_check.setObjectName("import_dlcs_check")
+ self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.import_dlcs_check)
self.button_info_layout = QtWidgets.QHBoxLayout()
self.button_info_layout.setObjectName("button_info_layout")
self.import_button = QtWidgets.QPushButton(ImportGroup)
@@ -50,11 +60,7 @@ class Ui_ImportGroup(object):
self.import_button.setSizePolicy(sizePolicy)
self.import_button.setObjectName("import_button")
self.button_info_layout.addWidget(self.import_button)
- self.info_label = QtWidgets.QLabel(ImportGroup)
- self.info_label.setText("")
- self.info_label.setObjectName("info_label")
- self.button_info_layout.addWidget(self.info_label)
- self.formLayout.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.button_info_layout)
+ self.formLayout.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.button_info_layout)
self.retranslateUi(ImportGroup)
QtCore.QMetaObject.connectSlotsByName(ImportGroup)
@@ -66,6 +72,8 @@ class Ui_ImportGroup(object):
self.app_name_label.setText(_translate("ImportGroup", "Override app name"))
self.import_folder_label.setText(_translate("ImportGroup", "Import all folders"))
self.import_folder_check.setText(_translate("ImportGroup", "Scan the installation path for game folders and import them"))
+ self.import_dlcs_label.setText(_translate("ImportGroup", "Import DLCs"))
+ self.import_dlcs_check.setText(_translate("ImportGroup", "If a game has DLCs, try to import them too"))
self.import_button.setText(_translate("ImportGroup", "Import Game"))
diff --git a/rare/ui/components/tabs/games/import_sync/import_group.ui b/rare/ui/components/tabs/games/import_sync/import_group.ui
index f85fb386..60a77630 100644
--- a/rare/ui/components/tabs/games/import_sync/import_group.ui
+++ b/rare/ui/components/tabs/games/import_sync/import_group.ui
@@ -7,7 +7,7 @@
0
0
501
- 136
+ 162
@@ -20,6 +20,9 @@
Import EGL game from a directory
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
-
@@ -59,7 +62,26 @@
+ -
+
+
+ Import DLCs
+
+
+
-
+
+
+
+ true
+
+
+
+ If a game has DLCs, try to import them too
+
+
+
+ -
-
@@ -74,13 +96,6 @@
- -
-
-
-
-
-
-
diff --git a/rare/utils/extra_widgets.py b/rare/utils/extra_widgets.py
index f75ad5ff..bb95e09a 100644
--- a/rare/utils/extra_widgets.py
+++ b/rare/utils/extra_widgets.py
@@ -36,7 +36,7 @@ from PyQt5.QtWidgets import (
from rare.utils.paths import tmp_dir
from rare.utils.qt_requests import QtRequestManager
-from rare.utils.utils import icon as qta_icon
+from rare.utils.misc import icon as qta_icon
logger = getLogger("ExtraWidgets")
diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py
index b6971c69..77fccfe6 100644
--- a/rare/utils/legendary_utils.py
+++ b/rare/utils/legendary_utils.py
@@ -2,20 +2,19 @@ import os
import platform
from logging import getLogger
-from PyQt5.QtCore import pyqtSignal, QCoreApplication, QObject, QRunnable, QStandardPaths
+from PyQt5.QtCore import pyqtSignal, QObject, QRunnable, QStandardPaths
from legendary.core import LegendaryCore
-from legendary.models.game import VerifyResult
-from legendary.utils.lfs import validate_files
-from rare.shared import LegendaryCoreSingleton
+from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs
+from rare.lgndr.api_monkeys import LgndrIndirectStatus
+from rare.lgndr.cli import LegendaryCLI
+from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton
from rare.utils import config_helper
logger = getLogger("Legendary Utils")
-def uninstall(app_name: str, core: LegendaryCore, options=None):
- if not options:
- options = {"keep_files": False}
+def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False, keep_config=False):
igame = core.get_installed_game(app_name)
# remove shortcuts link
@@ -40,31 +39,24 @@ def uninstall(app_name: str, core: LegendaryCore, options=None):
if os.path.exists(start_menu_shortcut):
os.remove(start_menu_shortcut)
- try:
- # Remove DLC first so directory is empty when game uninstall runs
- dlcs = core.get_dlc_for_game(app_name)
- for dlc in dlcs:
- if (idlc := core.get_installed_game(dlc.app_name)) is not None:
- logger.info(f'Uninstalling DLC "{dlc.app_name}"...')
- core.uninstall_game(idlc, delete_files=not options["keep_files"])
-
- logger.info(f'Removing "{igame.title}" from "{igame.install_path}"...')
- core.uninstall_game(
- igame, delete_files=not options["keep_files"], delete_root_directory=True
+ status = LgndrIndirectStatus()
+ LegendaryCLI(core).uninstall_game(
+ LgndrUninstallGameArgs(
+ app_name=app_name,
+ keep_files=keep_files,
+ indirect_status=status,
+ yes=True,
)
- logger.info("Game has been uninstalled.")
-
- except Exception as e:
- logger.warning(
- f"Removing game failed: {e!r}, please remove {igame.install_path} manually."
- )
- if not options["keep_files"]:
+ )
+ if not keep_config:
logger.info("Removing sections in config file")
config_helper.remove_section(app_name)
config_helper.remove_section(f"{app_name}.env")
config_helper.save_config()
+ return status.success, status.message
+
def update_manifest(app_name: str, core: LegendaryCore):
game = core.get_game(app_name)
@@ -78,146 +70,65 @@ def update_manifest(app_name: str, core: LegendaryCore):
new_manifest = core.load_manifest(new_manifest_data)
logger.debug(f"Base urls: {base_urls}")
# save manifest with version name as well for testing/downgrading/etc.
- core.lgd.save_manifest(
- game.app_name, new_manifest_data, version=new_manifest.meta.build_version
- )
-
-
-class VerifySignals(QObject):
- status = pyqtSignal(int, int, str)
- summary = pyqtSignal(int, int, str)
+ core.lgd.save_manifest(game.app_name, new_manifest_data, version=new_manifest.meta.build_version)
class VerifyWorker(QRunnable):
+ class Signals(QObject):
+ status = pyqtSignal(str, int, int, float, float)
+ result = pyqtSignal(str, bool, int, int)
+ error = pyqtSignal(str, str)
+
num: int = 0
total: int = 1 # set default to 1 to avoid DivisionByZero before it is initialized
def __init__(self, app_name):
super(VerifyWorker, self).__init__()
- self.signals = VerifySignals()
+ self.signals = VerifyWorker.Signals()
self.setAutoDelete(True)
self.core = LegendaryCoreSingleton()
+ self.args = ArgumentsSingleton()
self.app_name = app_name
+ def status_callback(self, num: int, total: int, percentage: float, speed: float):
+ self.signals.status.emit(self.app_name, num, total, percentage, speed)
+
def run(self):
- if not self.core.is_installed(self.app_name):
- logger.error(f'Game "{self.app_name}" is not installed')
- return
-
- logger.info(f'Loading installed manifest for "{self.app_name}"')
- igame = self.core.get_installed_game(self.app_name)
- manifest_data, _ = self.core.get_installed_manifest(self.app_name)
- manifest = self.core.load_manifest(manifest_data)
-
- files = sorted(manifest.file_manifest_list.elements,
- key=lambda a: a.filename.lower())
-
- # build list of hashes
- file_list = [(f.filename, f.sha_hash.hex()) for f in files]
- total = len(file_list)
- num = 0
- failed = []
- missing = []
-
- logger.info(f'Verifying "{igame.title}" version "{manifest.meta.build_version}"')
- repair_file = []
- for result, path, result_hash, _ in validate_files(igame.install_path, file_list):
- num += 1
- self.signals.status.emit(num, total, self.app_name)
-
- if result == VerifyResult.HASH_MATCH:
- repair_file.append(f"{result_hash}:{path}")
- continue
- elif result == VerifyResult.HASH_MISMATCH:
- logger.error(f'File does not match hash: "{path}"')
- repair_file.append(f"{result_hash}:{path}")
- failed.append(path)
- elif result == VerifyResult.FILE_MISSING:
- logger.error(f'File is missing: "{path}"')
- missing.append(path)
- else:
- logger.error(f'Other failure (see log), treating file as missing: "{path}"')
- missing.append(path)
-
- # always write repair file, even if all match
- if repair_file:
- repair_filename = os.path.join(self.core.lgd.get_tmp_path(), f'{self.app_name}.repair')
- with open(repair_filename, 'w') as f:
- f.write('\n'.join(repair_file))
- logger.debug(f'Written repair file to "{repair_filename}"')
-
- if not missing and not failed:
- logger.info('Verification finished successfully.')
- else:
- logger.warning(
- f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.')
- self.signals.summary.emit(len(failed), len(missing), self.app_name)
-
-
-# FIXME: lk: ah ef me sideways, we can't even import this thing properly
-# FIXME: lk: so copy it here
-def resolve_aliases(core: LegendaryCore, name):
- # make sure aliases exist if not yet created
- core.update_aliases(force=False)
- name = name.strip()
- # resolve alias (if any) to real app name
- return core.lgd.config.get(
- section='Legendary.aliases', option=name,
- fallback=core.lgd.aliases.get(name.lower(), name)
- )
-
-
-def import_game(core: LegendaryCore, app_name: str, path: str) -> str:
- _tr = QCoreApplication.translate
- logger.info(f"Import {app_name}")
- game = core.get_game(app_name, update_meta=False)
- if not game:
- return _tr("LgdUtils", "Could not get game for {}").format(app_name)
-
- if core.is_installed(app_name):
- logger.error(f"{game.app_title} is already installed")
- return _tr("LgdUtils", "{} is already installed").format(game.app_title)
-
- if not os.path.exists(path):
- logger.error("Path does not exist")
- return _tr("LgdUtils", "Path does not exist")
-
- manifest, igame = core.import_game(game, path)
- exe_path = os.path.join(path, manifest.meta.launch_exe.lstrip("/"))
-
- if not os.path.exists(exe_path):
- logger.error(f"Launch Executable of {game.app_title} does not exist")
- return _tr("LgdUtils", "Launch executable of {} does not exist").format(
- game.app_title
+ cli = LegendaryCLI(self.core)
+ status = LgndrIndirectStatus()
+ args = LgndrVerifyGameArgs(
+ app_name=self.app_name, indirect_status=status, verify_stdout=self.status_callback
)
- if game.is_dlc:
- release_info = game.metadata.get("mainGameItem", {}).get("releaseInfo")
- if release_info:
- main_game_appname = release_info[0]["appId"]
- main_game_title = game.metadata["mainGameItem"]["title"]
- if not core.is_installed(main_game_appname):
- return _tr("LgdUtils", "Game is a DLC, but {} is not installed").format(
- main_game_title
+ # lk: first pass, verify with the current manifest
+ repair_mode = False
+ result = cli.verify_game(
+ args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline
+ )
+ if result is None:
+ # lk: second pass with downloading the latest manifest
+ # lk: this happens if the manifest was not found and repair_mode was not requested
+ # lk: we already have checked if the directory exists before starting the worker
+ try:
+ # lk: this try-except block handles the exception caused by a missing manifest
+ # lk: and is raised only in the case we are offline
+ repair_mode = True
+ result = cli.verify_game(
+ args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline
)
- else:
- return _tr("LgdUtils", "Unable to get base game information for DLC")
+ if result is None:
+ raise ValueError
+ except ValueError:
+ self.signals.error.emit(self.app_name, status.message)
+ return
- total = len(manifest.file_manifest_list.elements)
- found = sum(
- os.path.exists(os.path.join(path, f.filename))
- for f in manifest.file_manifest_list.elements
- )
- ratio = found / total
+ success = result is not None and not any(result)
+ if success:
+ # lk: if verification was successful we delete the repair file and run the clean procedure
+ # lk: this could probably be cut down to what is relevant for this use-case and skip the `cli` call
+ igame = self.core.get_installed_game(self.app_name)
+ game = self.core.get_game(self.app_name, platform=igame.platform)
+ repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.app_name}.repair")
+ cli.install_game_cleanup(game=game, igame=igame, repair_mode=True, repair_file=repair_file)
- if ratio < 0.9:
- logger.warning(
- "Game files are missing. It may be not the latest version or it is corrupt"
- )
- # return False
- core.install_game(igame)
- if igame.needs_verification:
- logger.info(f"{igame.title} needs verification")
-
- logger.info(f"Successfully imported Game: {game.app_title}")
- return ""
+ self.signals.result.emit(self.app_name, success, *result)
diff --git a/rare/utils/utils.py b/rare/utils/misc.py
similarity index 98%
rename from rare/utils/utils.py
rename to rare/utils/misc.py
index 6b56d5b9..a6826571 100644
--- a/rare/utils/utils.py
+++ b/rare/utils/misc.py
@@ -4,7 +4,7 @@ import shlex
import subprocess
import sys
from logging import getLogger
-from typing import List
+from typing import List, Union
import qtawesome
import requests
@@ -157,7 +157,7 @@ def get_latest_version():
return "0.0.0"
-def get_size(b: int) -> str:
+def get_size(b: Union[int, float]) -> str:
for i in ["", "K", "M", "G", "T", "P", "E"]:
if b < 1024:
return f"{b:.2f}{i}B"
@@ -165,7 +165,10 @@ def get_size(b: int) -> str:
def get_rare_executable() -> List[str]:
- if platform.system() == "Linux" or platform.system() == "Darwin":
+ # lk: detech if nuitka
+ if "__compiled__" in globals():
+ executable = [sys.executable]
+ elif platform.system() == "Linux" or platform.system() == "Darwin":
# TODO flatpak
if p := os.environ.get("APPIMAGE"):
executable = [p]
diff --git a/rare/utils/models.py b/rare/utils/models.py
index 16f457d2..24c9d1a5 100644
--- a/rare/utils/models.py
+++ b/rare/utils/models.py
@@ -1,61 +1,7 @@
import os
-import platform as pf
-from dataclasses import field, dataclass
-from multiprocessing import Queue
-from typing import Union, List, Optional
+from typing import Union, List
from legendary.core import LegendaryCore
-from legendary.downloader.mp.manager import DLManager
-from legendary.models.downloading import AnalysisResult, ConditionCheckResult
-from legendary.models.game import Game, InstalledGame
-
-
-@dataclass
-class InstallOptionsModel:
- app_name: str
- base_path: str = ""
- max_shm: int = 1024
- max_workers: int = os.cpu_count() * 2
- repair: bool = False
- no_install: bool = False
- ignore_space_req: bool = False
- force: bool = False
- sdl_list: list = field(default_factory=lambda: [""])
- update: bool = False
- silent: bool = False
- platform: str = ""
- dl_optimizations: bool = False
- overlay: bool = False
- create_shortcut: bool = True
- install_preqs: bool = pf.system() == "Windows"
-
- def set_no_install(self, enabled: bool) -> None:
- self.no_install = enabled
-
-
-@dataclass
-class InstallDownloadModel:
- dlmanager: DLManager
- analysis: AnalysisResult
- game: Game
- igame: InstalledGame
- repair: bool
- repair_file: str
- res: ConditionCheckResult
-
-
-@dataclass
-class InstallQueueItemModel:
- status_q: Optional[Queue] = None
- download: Optional[InstallDownloadModel] = None
- options: Optional[InstallOptionsModel] = None
-
- def __bool__(self):
- return (
- (self.status_q is not None)
- and (self.download is not None)
- and (self.options is not None)
- )
class PathSpec:
@@ -80,9 +26,7 @@ class PathSpec:
@property
def wine_egl_programdata(self):
- return self.egl_programdata.replace("\\", "/").replace(
- "%PROGRAMDATA%", self.wine_programdata
- )
+ return self.egl_programdata.replace("\\", "/").replace("%PROGRAMDATA%", self.wine_programdata)
def wine_egl_prefixes(self, results: int = 0) -> Union[List[str], str]:
possible_prefixes = [
diff --git a/rare/widgets/rare_app.py b/rare/widgets/rare_app.py
index 0e0904e8..a6964350 100644
--- a/rare/widgets/rare_app.py
+++ b/rare/widgets/rare_app.py
@@ -11,7 +11,7 @@ from legendary.core import LegendaryCore
import rare.resources.resources
from rare.utils.paths import resources_path
-from rare.utils.utils import set_color_pallete, set_style_sheet
+from rare.utils.misc import set_color_pallete, set_style_sheet
class RareApp(QApplication):
diff --git a/requirements.txt b/requirements.txt
index 1a564d96..36151228 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,9 @@
+typing_extensions
requests
PyQt5
QtAwesome
psutil
pypresence
+setuptools
+legendary-gl
pywin32; platform_system == "Windows"