1
0
Fork 0
mirror of synced 2024-06-26 18:20:50 +12:00
Rare/rare/launcher/__init__.py

429 lines
16 KiB
Python
Raw Normal View History

import json
2023-01-09 09:02:58 +13:00
import platform
import subprocess
import time
import traceback
from argparse import Namespace
from logging import getLogger
2023-04-08 09:33:00 +12:00
from signal import signal, SIGINT, SIGTERM, strsignal
from typing import Optional
from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QUrl, QRunnable, QThreadPool, QSettings, Qt, pyqtSlot
2022-05-14 08:11:24 +12:00
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
from PyQt5.QtWidgets import QApplication
2023-03-19 04:14:01 +13:00
from legendary.models.game import SaveGameStatus
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.models.options import options
from rare.widgets.rare_app import RareApp, RareAppException
from .cloud_sync_dialog import CloudSyncDialog, CloudSyncDialogResult
from .console_dialog import ConsoleDialog
from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError
2023-02-13 05:13:05 +13:00
DETACHED_APP_NAMES = {
"0a2d9f6403244d12969e11da6713137b", # Fall Guys
"Fortnite",
"afdb5a85efcc45d8ae8e406e2121d81c", # Fortnite Battle Royale
"09e442f830a341f698b4da42abd98c9b", # Fortnite Festival
"d8f7763e07d74c209d760a679f9ed6ac", # Lego Fortnite
"Fortnite_Studio", # Unreal Editor for Fortnite
"dcfccf8d965a4f2281dddf9fead042de", # Homeworld Remastered Collection (issue#376)
}
2023-01-09 09:02:58 +13:00
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, args: InitArgs, rgame: RareGameSlim, sync_action=None):
super(PreLaunchThread, self).__init__()
self.signals = self.Signals()
self.logger = getLogger(type(self).__name__)
self.args = args
self.rgame = rgame
self.sync_action = sync_action
def run(self) -> None:
self.logger.info(f"Sync action: {self.sync_action}")
if self.sync_action == CloudSyncDialogResult.UPLOAD:
self.rgame.upload_saves(False)
elif self.sync_action == CloudSyncDialogResult.DOWNLOAD:
self.rgame.download_saves(False)
else:
self.logger.info("No sync action")
if args := self.prepare_launch(self.args):
self.signals.ready_to_launch.emit(args)
else:
return
def prepare_launch(self, args: InitArgs) -> Optional[LaunchArgs]:
try:
launch_args = get_launch_args(self.rgame, args)
except Exception as e:
self.signals.error_occurred.emit(str(e))
return None
if not launch_args:
return None
if launch_args.pre_launch_command:
proc = get_configured_process()
proc.setProcessEnvironment(launch_args.environment)
self.signals.started_pre_launch_command.emit()
proc.start(launch_args.pre_launch_command[0], launch_args.pre_launch_command[1:])
if launch_args.pre_launch_wait:
proc.waitForFinished(-1)
return launch_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):
super(RareLauncher, self).__init__(args, f"{type(self).__name__}_{args.app_name}_{{0}}.log")
self.socket: Optional[QLocalSocket] = None
self.console: Optional[ConsoleDialog] = None
self.game_process: QProcess = QProcess(self)
self.server: QLocalServer = QLocalServer(self)
self._hook.deleteLater()
self._hook = RareLauncherException(self, args, self)
2023-02-13 05:13:05 +13:00
self.success: bool = False
self.no_sync_on_exit = False
self.args = args
self.core = LegendaryCore()
game = self.core.get_game(args.app_name)
if not game:
self.logger.error(f"Game {args.app_name} not found. Exiting")
return
self.rgame = RareGameSlim(self.core, game)
language = self.settings.value(*options.language)
self.load_translator(language)
if QSettings(self).value(*options.log_games):
self.console = ConsoleDialog(game.app_title)
self.console.show()
self.game_process.finished.connect(self.__process_finished)
self.game_process.errorOccurred.connect(self.__process_errored)
if self.console:
self.game_process.readyReadStandardOutput.connect(self.__proc_log_stdout)
self.game_process.readyReadStandardError.connect(self.__proc_log_stderr)
self.console.term.connect(self.__proc_term)
self.console.kill.connect(self.__proc_kill)
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()
return
self.server.newConnection.connect(self.new_server_connection)
self.success = True
self.start_time = time.time()
@pyqtSlot()
def __proc_log_stdout(self):
self.console.log(
self.game_process.readAllStandardOutput().data().decode("utf-8", "ignore")
)
@pyqtSlot()
def __proc_log_stderr(self):
self.console.error(
self.game_process.readAllStandardError().data().decode("utf-8", "ignore")
)
@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:
2022-06-12 02:59:53 +12:00
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(vars(message)).encode("utf-8"))
self.socket.flush()
else:
self.logger.error("Can't send message")
2023-03-19 04:14:01 +13:00
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 = CloudSyncDialogResult.UPLOAD
self.__check_saved_finished(exit_code, action)
2023-03-19 04:14:01 +13:00
else:
sync_dialog = CloudSyncDialog(self.rgame.igame, dt_local, dt_remote)
sync_dialog.result_ready.connect(lambda a: self.__check_saved_finished(exit_code, a))
sync_dialog.open()
@pyqtSlot(int, int)
@pyqtSlot(int, CloudSyncDialogResult)
def __check_saved_finished(self, exit_code, action):
action = CloudSyncDialogResult(action)
if action == CloudSyncDialogResult.UPLOAD:
if self.console:
self.console.log("Uploading saves...")
2023-03-19 04:14:01 +13:00
self.rgame.upload_saves()
elif action == CloudSyncDialogResult.DOWNLOAD:
if self.console:
self.console.log("Downloading saves...")
2023-03-19 04:14:01 +13:00
self.rgame.download_saves()
else:
self.on_exit(exit_code)
@pyqtSlot(int, QProcess.ExitStatus)
def __process_finished(self, exit_code: int, exit_status: QProcess.ExitStatus):
2023-03-19 04:14:01 +13:00
self.logger.info("Game finished")
2023-03-20 08:23:44 +13:00
if self.rgame.auto_sync_saves:
2023-03-19 04:14:01 +13:00
self.check_saves_finished(exit_code)
else:
self.on_exit(exit_code)
@pyqtSlot(QProcess.ProcessError)
def __process_errored(self, error: QProcess.ProcessError):
self.error_occurred(self.game_process.errorString())
2023-03-19 04:14:01 +13:00
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)
2023-03-19 04:14:01 +13:00
self.send_message(
FinishedModel(
action=Actions.finished,
app_name=self.rgame.app_name,
exit_code=exit_code,
playtime=int(time.time() - self.start_time)
)
)
2022-06-12 02:59:53 +12:00
self.stop()
@pyqtSlot(object)
def launch_game(self, args: LaunchArgs):
# should never happen
if not args:
self.stop()
return
if self.console:
self.console.set_env(args.environment)
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.working_directory:
self.game_process.setWorkingDirectory(args.working_directory)
self.game_process.setProcessEnvironment(args.environment)
2022-08-01 11:22:37 +12:00
# 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
)
2022-08-01 11:22:37 +12:00
))
if self.rgame.app_name in DETACHED_APP_NAMES and platform.system() == "Windows":
2023-01-09 09:02:58 +13:00
self.game_process.deleteLater()
subprocess.Popen([args.executable] + args.arguments, cwd=args.working_directory,
env={i: args.environment.value(i) for i in args.environment.keys()})
2023-01-09 09:02:58 +13:00
if self.console:
self.console.log("Launching game as a detached process")
2023-01-09 09:02:58 +13:00
self.stop()
return
2023-02-13 05:13:05 +13:00
if self.args.dry_run:
self.logger.info("Dry run activated")
2023-02-13 05:13:05 +13:00
if self.console:
self.console.log(f"{args.executable} {' '.join(args.arguments)}")
self.console.log(f"Do not start {self.rgame.app_name}")
2023-02-13 05:13:05 +13:00
self.console.accept_close = True
print(args.executable, " ".join(args.arguments))
2023-02-13 05:13:05 +13:00
self.stop()
return
self.game_process.start(args.executable, args.arguments)
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.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
sync_dialog = CloudSyncDialog(self.rgame.igame, dt_local, dt_remote)
sync_dialog.result_ready.connect(self.__sync_ready)
sync_dialog.open()
@pyqtSlot(int)
@pyqtSlot(CloudSyncDialogResult)
def __sync_ready(self, action: CloudSyncDialogResult):
action = CloudSyncDialogResult(action)
if action == CloudSyncDialogResult.CANCEL:
self.no_sync_on_exit = True
2023-03-19 04:14:01 +13:00
if self.console:
if action == CloudSyncDialogResult.DOWNLOAD:
self.console.log("Downloading saves...")
elif action == CloudSyncDialogResult.UPLOAD:
self.console.log("Uploading 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. Trying to launch the game in offline mode")
args.offline = True
if not args.offline and self.rgame.auto_sync_saves:
self.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):
2023-05-09 04:58:49 +12:00
try:
if self.console:
self.game_process.readyReadStandardOutput.disconnect()
self.game_process.readyReadStandardError.disconnect()
if self.game_process.receivers(self.game_process.finished):
self.game_process.finished.disconnect()
if self.game_process.receivers(self.game_process.errorOccurred):
self.game_process.errorOccurred.disconnect()
2023-05-09 04:58:49 +12:00
except TypeError as e:
self.logger.error(f"Failed to disconnect process signals: {e}")
2022-06-12 02:59:53 +12:00
self.logger.info("Stopping server")
try:
self.server.close()
self.server.deleteLater()
except RuntimeError:
pass
self.processEvents()
if not self.console:
self.exit()
2023-01-09 09:02:58 +13:00
else:
self.console.on_process_exit(self.rgame.app_name, 0)
def launch(args: Namespace) -> int:
args = InitArgs.from_argparse(args)
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
app = RareLauncher(args)
2022-08-01 11:22:37 +12:00
app.setQuitOnLastWindowClosed(True)
2023-04-08 09:33:00 +12:00
# This prevents ghost QLocalSockets, which block the name, which makes it unable to start
# No handling for SIGKILL
def sighandler(s, frame):
app.logger.info(f"{strsignal(s)} received. Stopping")
2023-04-08 09:33:00 +12:00
app.stop()
app.exit(1)
signal(SIGINT, sighandler)
signal(SIGTERM, sighandler)
if not app.success:
app.stop()
app.exit(1)
return 1
app.start(args)
2022-08-01 11:22:37 +12:00
# app.exit_app.connect(lambda: app.exit(0))
2022-05-14 08:11:24 +12:00
return app.exec_()