import json import logging import platform import subprocess import sys import time import traceback from argparse import Namespace from logging import getLogger from signal import signal, SIGINT, SIGTERM, strsignal from typing import Union, Optional from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QUrl, QRunnable, QThreadPool, QSettings, Qt, pyqtSlot from PyQt5.QtGui import QDesktopServices from PyQt5.QtNetwork import QLocalServer, QLocalSocket from PyQt5.QtWidgets import QApplication from legendary.models.game import SaveGameStatus from rare.components.dialogs.cloud_save_dialog import CloudSaveDialog from rare.lgndr.core import LegendaryCore from rare.models.base_game import RareGameSlim from rare.models.launcher import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel from rare.widgets.rare_app import RareApp, RareAppException from .console import Console from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError logger = logging.getLogger("RareLauncher") DETACHED_APP_NAMES = [ "0a2d9f6403244d12969e11da6713137b" ] class PreLaunchThread(QRunnable): class Signals(QObject): ready_to_launch = pyqtSignal(LaunchArgs) started_pre_launch_command = pyqtSignal() pre_launch_command_finished = pyqtSignal(int) # exit_code error_occurred = pyqtSignal(str) def __init__(self, core: LegendaryCore, args: InitArgs, rgame: RareGameSlim, sync_action=None): super(PreLaunchThread, self).__init__() self.core = core self.signals = self.Signals() self.args = args self.rgame = rgame self.sync_action = sync_action def run(self) -> None: logger.info(f"Sync action: {self.sync_action}") if self.sync_action == CloudSaveDialog.UPLOAD: self.rgame.upload_saves(False) elif self.sync_action == CloudSaveDialog.DOWNLOAD: self.rgame.download_saves(False) else: logger.info("No sync action") args = self.prepare_launch(self.args) if not args: return self.signals.ready_to_launch.emit(args) def prepare_launch(self, args: InitArgs) -> Union[LaunchArgs, None]: try: args = get_launch_args(self.core, args) except Exception as e: self.signals.error_occurred.emit(str(e)) return None if not args: return None if args.pre_launch_command: proc = get_configured_process() proc.setProcessEnvironment(args.env) self.signals.started_pre_launch_command.emit() proc.start(args.pre_launch_command[0], args.pre_launch_command[1:]) if args.pre_launch_wait: proc.waitForFinished(-1) return args class SyncCheckWorker(QRunnable): class Signals(QObject): sync_state_ready = pyqtSignal() error_occurred = pyqtSignal(str) def __init__(self, core: LegendaryCore, rgame: RareGameSlim): super().__init__() self.signals = self.Signals() self.core = core self.rgame = rgame def run(self) -> None: try: self.rgame.update_saves() except Exception as e: self.signals.error_occurred.emit(str(e)) return self.signals.sync_state_ready.emit() class RareLauncherException(RareAppException): def __init__(self, app: 'RareLauncher', args: Namespace, parent=None): super(RareLauncherException, self).__init__(parent=parent) self.__app = app self.__args = args def _handler(self, exc_type, exc_value, exc_tb) -> bool: try: self.__app.send_message(ErrorModel( app_name=self.__args.app_name, action=Actions.error, error_string="".join(traceback.format_exception(exc_type, exc_value, exc_tb)) )) except RuntimeError: pass return False class RareLauncher(RareApp): exit_app = pyqtSignal() def __init__(self, args: InitArgs): log_file = f"Rare_Launcher_{args.app_name}" + "_{0}.log" super(RareLauncher, self).__init__(args, log_file) self._hook.deleteLater() self._hook = RareLauncherException(self, args, self) self.logger = getLogger(f"Launcher_{args.app_name}") self.success: bool = True self.no_sync_on_exit = False self.args = args self.core = LegendaryCore() self.rgame = RareGameSlim(self.core, self.core.get_game(args.app_name)) lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) self.console: Optional[Console] = None if QSettings().value("show_console", False, bool): self.console = Console() self.console.show() self.game_process: QProcess = QProcess(self) self.game_process.finished.connect(self.game_finished) self.game_process.errorOccurred.connect( lambda err: self.error_occurred(self.game_process.errorString())) if self.console: self.game_process.readyReadStandardOutput.connect( lambda: self.console.log( self.game_process.readAllStandardOutput().data().decode("utf-8", "ignore") ) ) self.game_process.readyReadStandardError.connect( lambda: self.console.error( self.game_process.readAllStandardError().data().decode("utf-8", "ignore") ) ) self.console.term.connect(self.__proc_term) self.console.kill.connect(self.__proc_kill) self.socket: Optional[QLocalSocket] = None self.server: QLocalServer = QLocalServer(self) ret = self.server.listen(f"rare_{args.app_name}") if not ret: self.logger.error(self.server.errorString()) self.logger.info("Server is running") self.server.close() self.success = False return self.server.newConnection.connect(self.new_server_connection) self.start_time = time.time() @pyqtSlot() def __proc_term(self): self.game_process.terminate() @pyqtSlot() def __proc_kill(self): self.game_process.kill() def new_server_connection(self): if self.socket is not None: try: self.socket.disconnectFromServer() except RuntimeError: pass self.logger.info("New connection") self.socket = self.server.nextPendingConnection() self.socket.disconnected.connect(self.socket_disconnected) self.socket.flush() def socket_disconnected(self): self.logger.info("Server disconnected") self.socket.deleteLater() self.socket = None def send_message(self, message: BaseModel): if self.socket: self.socket.write(json.dumps(message.__dict__).encode("utf-8")) self.socket.flush() else: self.logger.error("Can't send message") def check_saves_finished(self, exit_code: int): self.rgame.signals.widget.update.connect(lambda: self.on_exit(exit_code)) state, (dt_local, dt_remote) = self.rgame.save_game_state if state == SaveGameStatus.LOCAL_NEWER and not self.no_sync_on_exit: action = CloudSaveDialog.UPLOAD else: action = CloudSaveDialog(self.rgame.igame, dt_local, dt_remote).get_action() if action == CloudSaveDialog.UPLOAD: if self.console: self.console.log("upload saves...") self.rgame.upload_saves() elif action == CloudSaveDialog.DOWNLOAD: if self.console: self.console.log("Download saves...") self.rgame.download_saves() else: self.on_exit(exit_code) def game_finished(self, exit_code): self.logger.info("Game finished") if self.rgame.auto_sync_saves: self.check_saves_finished(exit_code) else: self.on_exit(exit_code) def on_exit(self, exit_code: int): if self.console: self.console.on_process_exit(self.core.get_game(self.rgame.app_name).app_title, exit_code) self.send_message( FinishedModel( action=Actions.finished, app_name=self.rgame.app_name, exit_code=exit_code, playtime=int(time.time() - self.start_time) ) ) self.stop() def launch_game(self, args: LaunchArgs): # should never happen if not args: self.stop() return if self.console: self.console.set_env(args.env) self.start_time = time.time() if args.is_origin_game: QDesktopServices.openUrl(QUrl(args.executable)) self.stop() # stop because it is no subprocess return if args.cwd: self.game_process.setWorkingDirectory(args.cwd) self.game_process.setProcessEnvironment(args.env) # send start message after process started self.game_process.started.connect(lambda: self.send_message( StateChangedModel( action=Actions.state_update, app_name=self.rgame.app_name, new_state=StateChangedModel.States.started ) )) if self.rgame.app_name in DETACHED_APP_NAMES and platform.system() == "Windows": self.game_process.deleteLater() subprocess.Popen([args.executable] + args.args, cwd=args.cwd, env={i: args.env.value(i) for i in args.env.keys()}) if self.console: self.console.log("Launching game detached") self.stop() return if self.args.dry_run: logger.info("Dry run activated") if self.console: self.console.log(f"{args.executable} {' '.join(args.args)}") self.console.log(f"Do not start {self.rgame.app_name}") self.console.accept_close = True print(args.executable, " ".join(args.args)) self.stop() return self.game_process.start(args.executable, args.args) def error_occurred(self, error_str: str): self.logger.warning(error_str) if self.console: self.console.on_process_exit(self.core.get_game(self.rgame.app_name).app_title, error_str) self.send_message(ErrorModel( error_string=error_str, app_name=self.rgame.app_name, action=Actions.error) ) self.stop() def start_prepare(self, sync_action=None): worker = PreLaunchThread(self.core, self.args, self.rgame, sync_action) worker.signals.ready_to_launch.connect(self.launch_game) worker.signals.error_occurred.connect(self.error_occurred) # worker.signals.started_pre_launch_command(None) QThreadPool.globalInstance().start(worker) def sync_ready(self): if self.rgame.is_save_up_to_date: if self.console: self.console.log("Sync worker ready. Sync not required") self.start_prepare() return _, (dt_local, dt_remote) = self.rgame.save_game_state dlg = CloudSaveDialog(self.rgame.igame, dt_local, dt_remote) action = dlg.get_action() if action == CloudSaveDialog.CANCEL: self.no_sync_on_exit = True if self.console: if action == CloudSaveDialog.DOWNLOAD: self.console.log("Downloading saves") elif action == CloudSaveDialog.UPLOAD: self.console.log("Uloading saves") self.start_prepare(action) def start(self, args: InitArgs): if not args.offline: try: if not self.core.login(): raise ValueError("You are not logged in") except ValueError: # automatically launch offline if available self.logger.error("Not logged in. Try to launch game offline") args.offline = True if not args.offline and self.rgame.auto_sync_saves: logger.info("Start sync worker") worker = SyncCheckWorker(self.core, self.rgame) worker.signals.error_occurred.connect(self.error_occurred) worker.signals.sync_state_ready.connect(self.sync_ready) QThreadPool.globalInstance().start(worker) return else: self.start_prepare() def stop(self): try: if self.console: self.game_process.readyReadStandardOutput.disconnect() self.game_process.readyReadStandardError.disconnect() self.game_process.finished.disconnect() self.game_process.errorOccurred.disconnect() except TypeError as e: logger.error(f"Failed to disconnect signals: {e}") self.logger.info("Stopping server") try: self.server.close() self.server.deleteLater() except RuntimeError: pass self.processEvents() if not self.console: self.exit() else: self.console.on_process_exit(self.rgame.app_name, 0) def start_game(args: Namespace): args = InitArgs.from_argparse(args) QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) app = RareLauncher(args) app.setQuitOnLastWindowClosed(True) # This prevents ghost QLocalSockets, which block the name, which makes it unable to start # No handling for SIGKILL def sighandler(s, frame): logger.info(f"{strsignal(s)} received. Stopping") app.stop() app.exit(1) signal(SIGINT, sighandler) signal(SIGTERM, sighandler) if not app.success: return app.start(args) # app.exit_app.connect(lambda: app.exit(0)) sys.exit(app.exec_())