1
0
Fork 0
mirror of synced 2024-06-23 08:40:45 +12:00

Merge pull request #197 from loathingKernel/apes_together_strong

Implement shim legendary classes with overloaded/modified functions
This commit is contained in:
Dummerle 2022-08-27 12:31:00 +02:00 committed by GitHub
commit 04cd397a2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1997 additions and 815 deletions

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "legendary"]
path = rare/legendary
url = https://github.com/dummerle/legendary
branch = rare

View file

@ -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

View file

@ -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"]

View file

@ -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()

View file

@ -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:

View file

@ -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 <b>{}</b> 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

View file

@ -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")

View file

@ -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))

View file

@ -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")

View file

@ -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 <b>{}</b> ?").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()

View file

@ -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)

View file

@ -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 <b>{}</b>").format(username)))
layout.addWidget(self.open_browser)
layout.addWidget(self.logout_button)
def logout(self):
reply = QMessageBox.question(

View file

@ -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: <b>")
+ self.igame.version
+ "</b> >> <b>"
+ self.game.app_version(self.igame.platform)
+ "</b>"
)
)
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

View file

@ -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")

View file

@ -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

View file

@ -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: (

View file

@ -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")

View file

@ -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

View file

@ -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 <b>{}</b> from <b>{}</b> to <b>{}</b>."
"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 <b>{}</b> 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("<b>{}</b> 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, <b>{}</b> file(s) corrupted, <b>{}</b> 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:

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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"<h4>{self.game.app_title}</h4>", parent=miniwidget)
self.title_label = ElideLabel(f"<b>{self.game.app_title}</b>", parent=miniwidget)
self.title_label.setAlignment(Qt.AlignTop)
self.title_label.setObjectName("game_widget")
minilayout.addWidget(self.title_label, stretch=2)

View file

@ -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")

View file

@ -29,7 +29,7 @@ class UninstalledIconWidget(BaseUninstalledWidget):
minilayout.setSpacing(0)
miniwidget.setLayout(minilayout)
self.title_label = ElideLabel(f"<h4>{game.app_title}</h4>", parent=miniwidget)
self.title_label = ElideLabel(f"<b>{game.app_title}</b>", parent=miniwidget)
self.title_label.setAlignment(Qt.AlignTop)
self.title_label.setObjectName("game_widget")
minilayout.addWidget(self.title_label, stretch=2)

View file

@ -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):

View file

@ -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()
"""

View file

@ -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: <b>{}</b> 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: <b>{}</b> - {}").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 <b>{}</b>").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()

View file

@ -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):

View file

@ -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)

View file

@ -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):

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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):

View file

@ -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):

View file

@ -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)

@ -1 +0,0 @@
Subproject commit 50f71cbd9b2ae0b31615e8f7d8d8595922d3cdf3

5
rare/lgndr/__init__.py Normal file
View file

@ -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.
"""

136
rare/lgndr/api_arguments.py Normal file
View file

@ -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

View file

@ -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())

106
rare/lgndr/api_monkeys.py Normal file
View file

@ -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)

623
rare/lgndr/cli.py Normal file
View file

@ -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

97
rare/lgndr/core.py Normal file
View file

@ -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

25
rare/lgndr/downloading.py Normal file
View file

@ -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

229
rare/lgndr/manager.py Normal file
View file

@ -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

65
rare/models/install.py Normal file
View file

@ -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)

View file

@ -1,6 +1,6 @@
from PyQt5.QtCore import QObject, pyqtSignal
from rare.utils.models import InstallOptionsModel
from .install import InstallOptionsModel
class GlobalSignals(QObject):

View file

@ -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

View file

@ -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"))

View file

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>501</width>
<height>136</height>
<height>162</height>
</rect>
</property>
<property name="windowTitle">
@ -20,6 +20,9 @@
<string>Import EGL game from a directory</string>
</property>
<layout class="QFormLayout" name="formLayout">
<property name="labelAlignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<item row="0" column="0">
<widget class="QLabel" name="path_edit_label">
<property name="text">
@ -59,7 +62,26 @@
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="import_dlcs_label">
<property name="text">
<string>Import DLCs</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QCheckBox" name="import_dlcs_check">
<property name="font">
<font>
<italic>true</italic>
</font>
</property>
<property name="text">
<string>If a game has DLCs, try to import them too</string>
</property>
</widget>
</item>
<item row="4" column="1">
<layout class="QHBoxLayout" name="button_info_layout">
<item>
<widget class="QPushButton" name="import_button">
@ -74,13 +96,6 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="info_label">
<property name="text">
<string notr="true"/>
</property>
</widget>
</item>
</layout>
</item>
</layout>

View file

@ -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")

View file

@ -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)

View file

@ -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]

View file

@ -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 = [

View file

@ -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):

View file

@ -1,6 +1,9 @@
typing_extensions
requests
PyQt5
QtAwesome
psutil
pypresence
setuptools
legendary-gl
pywin32; platform_system == "Windows"