Merge pull request #202 from Dummerle/rework_game_launch
Add game helper to launch games in a detached process
This commit is contained in:
commit
e2f55f0f21
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
@ -7,7 +7,6 @@ from argparse import ArgumentParser
|
|||
|
||||
|
||||
def main():
|
||||
|
||||
# fix cx_freeze
|
||||
import multiprocessing
|
||||
|
||||
|
@ -53,6 +52,22 @@ def main():
|
|||
launch_parser = subparsers.add_parser("launch")
|
||||
launch_parser.add_argument("app_name", help="Name of the app", metavar="<App Name>")
|
||||
|
||||
launch_minimal_parser = subparsers.add_parser("start")
|
||||
launch_minimal_parser.add_argument("app_name", help="AppName of the game to launch",
|
||||
metavar="<App Name>", action="store")
|
||||
launch_minimal_parser.add_argument("--offline", help="Launch game offline",
|
||||
action="store_true")
|
||||
launch_minimal_parser.add_argument("--skip_update_check", help="Do not check for updates",
|
||||
action="store_true")
|
||||
launch_minimal_parser.add_argument('--wine-bin', dest='wine_bin', action='store', metavar='<wine binary>',
|
||||
default=os.environ.get('LGDRY_WINE_BINARY', None),
|
||||
help='Set WINE binary to use to launch the app')
|
||||
launch_minimal_parser.add_argument('--wine-prefix', dest='wine_pfx', action='store', metavar='<wine pfx path>',
|
||||
default=os.environ.get('LGDRY_WINE_PREFIX', None),
|
||||
help='Set WINE prefix to use')
|
||||
launch_minimal_parser.add_argument("--ask-alyways-sync", help="Ask for cloud saves",
|
||||
action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.desktop_shortcut:
|
||||
|
@ -74,6 +89,11 @@ def main():
|
|||
|
||||
print(f"Rare {__version__} Codename: {code_name}")
|
||||
return
|
||||
if args.subparser == "start":
|
||||
from rare import game_launch_helper as helper
|
||||
helper.start_game(args)
|
||||
return
|
||||
|
||||
from rare.utils import singleton
|
||||
|
||||
try:
|
||||
|
|
|
@ -2,7 +2,7 @@ import datetime
|
|||
import sys
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
from typing import Union, List
|
||||
from typing import Union, List, Dict
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, QRunnable, QThreadPool, Qt, QSettings
|
||||
from PyQt5.QtWidgets import QDialog, QMessageBox, QSizePolicy, QLayout, QApplication
|
||||
|
@ -139,7 +139,7 @@ class CloudSaveUtils(QObject):
|
|||
|
||||
self.thread_pool = QThreadPool.globalInstance()
|
||||
|
||||
def get_latest_saves(self, saves: List[SaveGameFile]) -> dict:
|
||||
def get_latest_saves(self, saves: List[SaveGameFile]) -> Dict[str, SaveGameFile]:
|
||||
save_games = set()
|
||||
for igame in self.core.get_installed_list():
|
||||
game = self.core.get_game(igame.app_name)
|
||||
|
|
|
@ -1,40 +1,119 @@
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
|
||||
from PyQt5.QtCore import QObject, QSettings, QProcess, QProcessEnvironment, pyqtSignal, QUrl
|
||||
from PyQt5.QtCore import QObject, QSettings, QProcess, QProcessEnvironment, pyqtSignal, QUrl, QTimer
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtNetwork import QLocalSocket
|
||||
from PyQt5.QtWidgets import QMessageBox, QPushButton
|
||||
from legendary.models.game import LaunchParameters, InstalledGame
|
||||
|
||||
from rare.components.dialogs.uninstall_dialog import UninstallDialog
|
||||
from rare.components.extra.console import Console
|
||||
from rare.components.tabs.games import CloudSaveUtils
|
||||
from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton
|
||||
from rare.utils import legendary_utils
|
||||
from rare.utils import utils
|
||||
from rare.utils.meta import RareGameMeta
|
||||
from rare.game_launch_helper import message_models
|
||||
|
||||
logger = getLogger("GameUtils")
|
||||
|
||||
|
||||
class GameProcess(QProcess):
|
||||
game_finished = pyqtSignal(int, str)
|
||||
class GameProcess(QObject):
|
||||
game_finished = pyqtSignal(int, str) # exit_code, appname
|
||||
game_launched = pyqtSignal(str)
|
||||
tried_connections = 0
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
def __init__(self, app_name):
|
||||
def __init__(self, app_name: str, on_startup=False, always_ask_sync: bool= False):
|
||||
super(GameProcess, self).__init__()
|
||||
self.app_name = app_name
|
||||
self.finished.connect(self._game_finished)
|
||||
self.on_startup = on_startup
|
||||
self.game = LegendaryCoreSingleton().get_game(app_name)
|
||||
self.socket = QLocalSocket()
|
||||
self.socket.connected.connect(self._socket_connected)
|
||||
self.socket.errorOccurred.connect(self._error_occurred)
|
||||
self.socket.readyRead.connect(self._message_available)
|
||||
self.always_ask_sync = always_ask_sync
|
||||
|
||||
def _game_finished(self, exit_code):
|
||||
def close_socket():
|
||||
try:
|
||||
self.socket.close()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
self.socket.disconnected.connect(close_socket)
|
||||
self.timer = QTimer()
|
||||
if not on_startup:
|
||||
# wait a short time for process started
|
||||
self.timer.timeout.connect(self.connect_to_server)
|
||||
self.timer.start(200)
|
||||
else:
|
||||
# nothing happens, if no server available
|
||||
self.connect_to_server()
|
||||
|
||||
def connect_to_server(self):
|
||||
self.socket.connectToServer(f"rare_{self.app_name}")
|
||||
self.tried_connections += 1
|
||||
|
||||
if self.tried_connections > 50: # 10 seconds
|
||||
QMessageBox.warning(None, "Error", self.tr("Connection to game process failed (Timeout)"))
|
||||
self.timer.stop()
|
||||
self.game_finished.emit(1, self.app_name)
|
||||
|
||||
def _message_available(self):
|
||||
message = self.socket.readAll().data()
|
||||
if not message.startswith(b"{"):
|
||||
logger.error(f"Received unsupported message: {message.decode('utf-8')}")
|
||||
return
|
||||
try:
|
||||
self.game_finished.emit(exit_code, self.app_name)
|
||||
except RuntimeError: # Do not raise an exception, if rare finished, but game not
|
||||
pass
|
||||
data = json.loads(message)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(e)
|
||||
logger.error("Could not load json data")
|
||||
return
|
||||
|
||||
action = data.get("action", -1)
|
||||
|
||||
if action == -1:
|
||||
logger.error("Got unexpected action")
|
||||
elif action == message_models.Actions.finished:
|
||||
logger.info(f"{self.app_name} finished")
|
||||
model = message_models.FinishedModel.from_json(data)
|
||||
self.socket.close()
|
||||
self._game_finished(model.exit_code)
|
||||
elif action == message_models.Actions.error:
|
||||
model = message_models.ErrorModel.from_json(data)
|
||||
logger.error(f"Error in game {self.game.app_title}: {model.error_string}")
|
||||
QMessageBox.warning(None, "Error", self.tr(
|
||||
"Error in game {}: \n{}").format(self.game.app_title, model.error_string))
|
||||
elif action == message_models.Actions.state_update:
|
||||
model = message_models.StateChangedModel.from_json(data)
|
||||
if model.new_state == message_models.StateChangedModel.States.started:
|
||||
logger.info("Launched Game")
|
||||
self.game_launched.emit(self.app_name)
|
||||
|
||||
def _socket_connected(self):
|
||||
self.timer.stop()
|
||||
self.timer.deleteLater()
|
||||
logger.info(f"Connection established for {self.app_name}")
|
||||
if self.on_startup:
|
||||
logger.info(f"Found {self.app_name} running at startup")
|
||||
|
||||
# FIXME run this after startup, widgets do not exist at this time
|
||||
QTimer.singleShot(1000, lambda: self.game_launched.emit(self.app_name))
|
||||
|
||||
def _error_occurred(self, _):
|
||||
if self.on_startup:
|
||||
self.socket.close()
|
||||
self.deleteLater()
|
||||
self._game_finished(-1234) # 1234 is exit code for startup
|
||||
logger.error(f"{self.app_name}: {self.socket.errorString()}")
|
||||
|
||||
def _game_finished(self, exit_code: int):
|
||||
self.game_finished.emit(exit_code, self.app_name)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -63,6 +142,12 @@ class GameUtils(QObject):
|
|||
self.cloud_save_utils.sync_finished.connect(self.sync_finished)
|
||||
self.game_meta = RareGameMeta()
|
||||
|
||||
for igame in self.core.get_installed_list():
|
||||
game_process = GameProcess(igame.app_name, True)
|
||||
game_process.game_finished.connect(self.game_finished)
|
||||
game_process.game_launched.connect(self.game_launched.emit)
|
||||
self.running_games[igame.app_name] = game_process
|
||||
|
||||
def uninstall_game(self, app_name) -> bool:
|
||||
# returns if uninstalled
|
||||
game = self.core.get_game(app_name)
|
||||
|
@ -95,6 +180,7 @@ class GameUtils(QObject):
|
|||
game = self.core.get_game(app_name)
|
||||
dont_sync_after_finish = False
|
||||
|
||||
# TODO move this to helper
|
||||
if game.supports_cloud_saves and not offline:
|
||||
try:
|
||||
sync = self.cloud_save_utils.sync_before_launch_game(app_name)
|
||||
|
@ -123,76 +209,33 @@ class GameUtils(QObject):
|
|||
wine_pfx: str = None,
|
||||
ask_always_sync: bool = False,
|
||||
):
|
||||
if self.args.offline:
|
||||
offline = True
|
||||
game = self.core.get_game(app_name)
|
||||
igame = self.core.get_installed_game(app_name)
|
||||
executable = utils.get_rare_executable()
|
||||
executable, args = executable[0], executable[1:]
|
||||
args.extend([
|
||||
"start", app_name
|
||||
])
|
||||
if offline:
|
||||
args.append("--offline")
|
||||
if skip_update_check:
|
||||
args.append("--skip-update-check")
|
||||
if wine_bin:
|
||||
args.extend(["--wine-bin", wine_bin])
|
||||
if wine_pfx:
|
||||
args.extend(["--wine-prefix", wine_pfx])
|
||||
if ask_always_sync:
|
||||
args.extend("--ask-always-sync")
|
||||
|
||||
meta_data = self.game_meta.get_game(app_name)
|
||||
meta_data.last_played = datetime.datetime.now()
|
||||
self.game_meta.set_game(app_name, meta_data)
|
||||
|
||||
if not game:
|
||||
logger.error(f"{app_name} not found")
|
||||
self.finished.emit(app_name, self.tr("Game not found in available games"))
|
||||
return
|
||||
|
||||
if QSettings().value("confirm_start", False, bool):
|
||||
if (
|
||||
not QMessageBox.question(
|
||||
None,
|
||||
"Launch",
|
||||
self.tr("Do you want to launch {}").format(game.app_title),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
) == QMessageBox.Yes
|
||||
):
|
||||
logger.info("Cancel Startup")
|
||||
self.finished.emit(app_name, "")
|
||||
return
|
||||
logger.info(f"Launching {game.app_title}")
|
||||
|
||||
if game.third_party_store == "Origin":
|
||||
offline = False
|
||||
else:
|
||||
if not igame:
|
||||
logger.error(f"{app_name} is not installed")
|
||||
if game.is_dlc:
|
||||
logger.error("Game is dlc")
|
||||
self.finished.emit(
|
||||
app_name, self.tr("Game is a DLC. Please launch base game instead")
|
||||
)
|
||||
return
|
||||
if not os.path.exists(igame.install_path):
|
||||
logger.error("Game doesn't exist")
|
||||
self.finished.emit(
|
||||
app_name,
|
||||
self.tr(
|
||||
"Game files of {} do not exist. Please install game"
|
||||
).format(game.app_title),
|
||||
)
|
||||
return
|
||||
|
||||
def _launch_real():
|
||||
process = self._get_process(app_name, env)
|
||||
self.console.log("\n"*2)
|
||||
if game.third_party_store != "Origin":
|
||||
self._launch_game(igame, process, offline, skip_update_check, ask_always_sync)
|
||||
else:
|
||||
self._launch_origin(app_name, process)
|
||||
|
||||
env = self.core.get_app_environment(app_name, wine_pfx=wine_pfx)
|
||||
pre_cmd, wait = self.core.get_pre_launch_command(app_name)
|
||||
if pre_cmd:
|
||||
pre_cmd = pre_cmd.split()
|
||||
pre_proc = self._launch_pre_command(env)
|
||||
self.console.log("\n"*2)
|
||||
pre_proc.start(pre_cmd[0], pre_cmd[1:])
|
||||
if wait:
|
||||
pre_proc.finished.connect(_launch_real)
|
||||
return
|
||||
_launch_real()
|
||||
# kill me, if I don't change it before commit
|
||||
QProcess.startDetached(executable, args)
|
||||
logger.info(f"Start new Process: ({executable} {' '.join(args)})")
|
||||
game_process = GameProcess(app_name, ask_always_sync)
|
||||
game_process.game_finished.connect(self.game_finished)
|
||||
game_process.game_launched.connect(self.game_launched.emit)
|
||||
self.running_games[app_name] = game_process
|
||||
|
||||
def game_finished(self, exit_code, app_name):
|
||||
if exit_code == -1234:
|
||||
self.running_games.pop(app_name)
|
||||
logger.info(f"Game exited with exit code: {exit_code}")
|
||||
self.console.log(f"Game exited with code: {exit_code}")
|
||||
self.signals.set_discord_rpc.emit("")
|
||||
|
@ -314,76 +357,6 @@ class GameUtils(QObject):
|
|||
command.append(origin_uri)
|
||||
process.start(command[0], command[1:])
|
||||
|
||||
def _launch_game(self, igame: InstalledGame, process: QProcess, offline: bool,
|
||||
skip_update_check: bool, ask_always_sync: bool):
|
||||
if not offline: # skip for update
|
||||
if not skip_update_check and not self.core.is_noupdate_game(igame.app_name):
|
||||
# check updates
|
||||
try:
|
||||
latest = self.core.get_asset(
|
||||
igame.app_name, igame.platform, update=False
|
||||
)
|
||||
except ValueError:
|
||||
self.finished.emit(igame.app_name, self.tr("Metadata doesn't exist"))
|
||||
return
|
||||
else:
|
||||
if latest.build_version != igame.version:
|
||||
self.finished.emit(igame.app_name, self.tr("Please update game"))
|
||||
return
|
||||
|
||||
params: LaunchParameters = self.core.get_launch_parameters(
|
||||
app_name=igame.app_name, offline=offline
|
||||
)
|
||||
|
||||
full_params = list()
|
||||
|
||||
if os.environ.get("container") == "flatpak":
|
||||
full_params.extend(["flatpak-spawn", "--host"])
|
||||
|
||||
full_params.extend(params.launch_command)
|
||||
full_params.append(
|
||||
os.path.join(params.game_directory, params.game_executable)
|
||||
)
|
||||
full_params.extend(params.game_parameters)
|
||||
full_params.extend(params.egl_parameters)
|
||||
full_params.extend(params.user_parameters)
|
||||
|
||||
process.setWorkingDirectory(params.working_directory)
|
||||
|
||||
if platform.system() != "Windows":
|
||||
# wine prefixes
|
||||
for env in ["STEAM_COMPAT_DATA_PATH", "WINEPREFIX"]:
|
||||
if val := process.processEnvironment().value(env, ""):
|
||||
if not os.path.exists(val):
|
||||
try:
|
||||
os.makedirs(val)
|
||||
except PermissionError as e:
|
||||
logger.error(str(e))
|
||||
QMessageBox.warning(
|
||||
None,
|
||||
"Error",
|
||||
self.tr(
|
||||
"Error while launching {}. No permission to create {} for {}"
|
||||
).format(igame.title, val, env),
|
||||
)
|
||||
process.deleteLater()
|
||||
return
|
||||
# check wine executable
|
||||
|
||||
if shutil.which(full_params[0]) is None:
|
||||
QMessageBox.warning(None, "Warning", self.tr("'{}' does not exist").format(full_params[0]))
|
||||
return
|
||||
running_game = RunningGameModel(
|
||||
process=process, app_name=igame.app_name, always_ask_sync=ask_always_sync
|
||||
)
|
||||
process.start(full_params[0], full_params[1:])
|
||||
|
||||
self.game_launched.emit(igame.app_name)
|
||||
self.signals.set_discord_rpc.emit(igame.app_name)
|
||||
logger.info(f"{igame.title} launched")
|
||||
|
||||
self.running_games[igame.app_name] = running_game
|
||||
|
||||
def sync_finished(self, app_name):
|
||||
if app_name in self.launch_queue.keys():
|
||||
self.cloud_save_finished.emit(app_name)
|
||||
|
|
211
rare/game_launch_helper/__init__.py
Normal file
211
rare/game_launch_helper/__init__.py
Normal file
|
@ -0,0 +1,211 @@
|
|||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from argparse import Namespace
|
||||
from logging import getLogger
|
||||
from typing import Union
|
||||
|
||||
from PyQt5.QtCore import QObject, QProcess, pyqtSignal, QUrl, QRunnable, QThreadPool
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError
|
||||
from .message_models import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel
|
||||
from ..shared import LegendaryCoreSingleton
|
||||
|
||||
|
||||
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):
|
||||
super(PreLaunchThread, self).__init__()
|
||||
self.core = LegendaryCoreSingleton()
|
||||
self.app_name = args.app_name
|
||||
self.signals = self.Signals()
|
||||
|
||||
def run(self) -> None:
|
||||
args = self.prepare_launch(self.app_name)
|
||||
if not args:
|
||||
return
|
||||
self.signals.ready_to_launch.emit(args)
|
||||
|
||||
def prepare_launch(self, app_name) -> Union[LaunchArgs, None]:
|
||||
try:
|
||||
args = get_launch_args(self.core, InitArgs(app_name))
|
||||
except GameArgsError 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 GameProcessHelper(QObject):
|
||||
game_process: QProcess
|
||||
server: QLocalServer
|
||||
socket: QLocalSocket = None
|
||||
exit_app = pyqtSignal()
|
||||
success: bool = True
|
||||
|
||||
def __init__(self, app_name: str):
|
||||
super(GameProcessHelper, self).__init__()
|
||||
self.game_process = QProcess()
|
||||
self.app_name = app_name
|
||||
self.logger = getLogger(self.app_name)
|
||||
self.core = LegendaryCoreSingleton(True)
|
||||
|
||||
self.server = QLocalServer()
|
||||
ret = self.server.listen(f"rare_{self.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.game_process.finished.connect(self.game_finished)
|
||||
self.game_process.errorOccurred.connect(
|
||||
lambda err: self.error_occurred(self.game_process.errorString()))
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
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.deleteLater)
|
||||
self.socket.disconnected.connect(lambda: self.logger.info("Server disconnected"))
|
||||
|
||||
self.socket.flush()
|
||||
|
||||
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 game_finished(self, exit_code):
|
||||
self.logger.info("game finished")
|
||||
self.send_message(
|
||||
FinishedModel(
|
||||
action=Actions.finished,
|
||||
app_name=self.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
|
||||
|
||||
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.start(args.executable, args.args)
|
||||
self.send_message(
|
||||
StateChangedModel(
|
||||
action=Actions.state_update, app_name=self.app_name,
|
||||
new_state=StateChangedModel.States.started
|
||||
)
|
||||
)
|
||||
|
||||
def error_occurred(self, error_str: str):
|
||||
self.logger.warning(error_str)
|
||||
self.send_message(ErrorModel(
|
||||
error_string=error_str, app_name=self.app_name,
|
||||
action=Actions.error)
|
||||
)
|
||||
self.stop()
|
||||
|
||||
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
|
||||
|
||||
worker = PreLaunchThread(args)
|
||||
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 stop(self):
|
||||
self.logger.info("Stopping server")
|
||||
self.server.close()
|
||||
self.server.deleteLater()
|
||||
self.exit_app.emit()
|
||||
|
||||
|
||||
def start_game(args: Namespace):
|
||||
args = InitArgs.from_argparse(args)
|
||||
logging.basicConfig(
|
||||
format="[%(name)s] %(levelname)s: %(message)s",
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
helper = GameProcessHelper(args.app_name)
|
||||
|
||||
def excepthook(exc_type, exc_value, exc_tb):
|
||||
tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
helper.logger.fatal(tb)
|
||||
try:
|
||||
helper.send_message(ErrorModel(
|
||||
app_name=args.app_name,
|
||||
action=Actions.error,
|
||||
error_string=tb
|
||||
))
|
||||
except RuntimeError:
|
||||
pass
|
||||
helper.stop()
|
||||
|
||||
sys.excepthook = excepthook
|
||||
if not helper.success:
|
||||
return
|
||||
helper.start(args)
|
||||
helper.exit_app.connect(lambda: app.exit(0))
|
||||
|
||||
# this button is for debug. Closing with keyboard interrupt does not kill the server
|
||||
# quit_button = QPushButton("Quit")
|
||||
# quit_button.show()
|
||||
# quit_button.clicked.connect(lambda: app.exit(0))
|
||||
app.exec_()
|
167
rare/game_launch_helper/lgd_helper.py
Normal file
167
rare/game_launch_helper/lgd_helper.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
from typing import List, Tuple
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
from legendary.core import LegendaryCore
|
||||
from legendary.models.game import InstalledGame, LaunchParameters
|
||||
|
||||
logger = getLogger("Helper")
|
||||
|
||||
|
||||
class GameArgsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class InitArgs:
|
||||
app_name: str
|
||||
offline: bool = False
|
||||
skip_version_check: bool = False
|
||||
wine_prefix: str = ""
|
||||
wine_bin: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_argparse(cls, args):
|
||||
return cls(
|
||||
app_name=args.app_name,
|
||||
offline=args.offline,
|
||||
skip_version_check=args.skip_update_check,
|
||||
wine_bin=args.wine_bin,
|
||||
wine_prefix=args.wine_pfx
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LaunchArgs:
|
||||
executable: str = ""
|
||||
args: List[str] = None
|
||||
cwd: str = None
|
||||
env: QProcessEnvironment = None
|
||||
pre_launch_command: str = ""
|
||||
pre_launch_wait: bool = False
|
||||
is_origin_game: bool = False # only for windows to launch as url
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.executable)
|
||||
|
||||
|
||||
def get_origin_params(core: LegendaryCore, app_name, offline: bool,
|
||||
launch_args: LaunchArgs) -> LaunchArgs:
|
||||
origin_uri = core.get_origin_uri(app_name, offline)
|
||||
if platform.system() == "Windows":
|
||||
launch_args.executable = origin_uri
|
||||
launch_args.args = []
|
||||
# only set it here true, because on linux it is a launch command like every other game
|
||||
launch_args.is_origin_game = True
|
||||
return launch_args
|
||||
|
||||
command = core.get_app_launch_command(app_name)
|
||||
if not os.path.exists(command[0]) and shutil.which(command[0]) is None:
|
||||
return launch_args
|
||||
command.append(origin_uri)
|
||||
|
||||
env = core.get_app_environment(app_name)
|
||||
launch_args.env = QProcessEnvironment.systemEnvironment()
|
||||
for name, value in env:
|
||||
launch_args.env.insert(name, value)
|
||||
|
||||
launch_args.executable = command[0]
|
||||
launch_args.args = command[1:]
|
||||
return launch_args
|
||||
|
||||
|
||||
def get_game_params(core: LegendaryCore, igame: InstalledGame, args: InitArgs,
|
||||
launch_args: LaunchArgs) -> LaunchArgs:
|
||||
if not args.offline: # skip for update
|
||||
if not args.skip_version_check and not core.is_noupdate_game(igame.app_name):
|
||||
# check updates
|
||||
try:
|
||||
latest = core.get_asset(
|
||||
igame.app_name, igame.platform, update=False
|
||||
)
|
||||
except ValueError:
|
||||
raise GameArgsError("Metadata doesn't exist")
|
||||
else:
|
||||
if latest.build_version != igame.version:
|
||||
raise GameArgsError("Game is not up to date. Please update first")
|
||||
|
||||
params: LaunchParameters = core.get_launch_parameters(
|
||||
app_name=igame.app_name, offline=args.offline
|
||||
)
|
||||
|
||||
full_params = list()
|
||||
|
||||
if os.environ.get("container") == "flatpak":
|
||||
full_params.extend(["flatpak-spawn", "--host"])
|
||||
|
||||
full_params.extend(params.launch_command)
|
||||
full_params.append(
|
||||
os.path.join(params.game_directory, params.game_executable)
|
||||
)
|
||||
full_params.extend(params.game_parameters)
|
||||
full_params.extend(params.egl_parameters)
|
||||
full_params.extend(params.user_parameters)
|
||||
|
||||
launch_args.executable = full_params[0]
|
||||
launch_args.args = full_params[1:]
|
||||
|
||||
launch_args.env = QProcessEnvironment.systemEnvironment()
|
||||
for name, value in params.environment.items():
|
||||
launch_args.env.insert(name, value)
|
||||
launch_args.cwd = params.working_directory
|
||||
return launch_args
|
||||
|
||||
|
||||
def get_launch_args(core: LegendaryCore, args: InitArgs = None) -> LaunchArgs:
|
||||
game = core.get_game(args.app_name)
|
||||
igame = core.get_installed_game(args.app_name)
|
||||
|
||||
resp = LaunchArgs()
|
||||
|
||||
if not game:
|
||||
raise GameArgsError(f"Could not find metadata for ")
|
||||
|
||||
if game.third_party_store == "Origin":
|
||||
args.offline = False
|
||||
else:
|
||||
if not igame:
|
||||
raise GameArgsError("Game is not installed or has unsupported format")
|
||||
|
||||
if game.is_dlc:
|
||||
raise GameArgsError("Game is a DLC")
|
||||
if not os.path.exists(igame.install_path):
|
||||
raise GameArgsError("Game path does not exist")
|
||||
|
||||
if game.third_party_store == "Origin":
|
||||
resp = get_origin_params(core, args.app_name, args.offline, resp)
|
||||
else:
|
||||
resp = get_game_params(core, igame, args, resp)
|
||||
|
||||
pre_cmd, wait = core.get_pre_launch_command(args.app_name)
|
||||
resp.pre_launch_command, resp.pre_launch_wait = pre_cmd, wait
|
||||
return resp
|
||||
|
||||
|
||||
def get_configured_process(env: dict = None):
|
||||
proc = QProcess()
|
||||
proc.readyReadStandardOutput.connect(
|
||||
lambda: logger.info(
|
||||
str(proc.readAllStandardOutput().data(), "utf-8", "ignore")
|
||||
)
|
||||
)
|
||||
proc.readyReadStandardError.connect(
|
||||
lambda: logger.info(
|
||||
str(proc.readAllStandardError().data(), "utf-8", "ignore")
|
||||
)
|
||||
)
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
if env:
|
||||
for e in env:
|
||||
environment.insert(e, env[e])
|
||||
proc.setProcessEnvironment(environment)
|
||||
|
||||
return proc
|
67
rare/game_launch_helper/message_models.py
Normal file
67
rare/game_launch_helper/message_models.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class Actions:
|
||||
finished = 0
|
||||
error = 1
|
||||
message = 2
|
||||
state_update = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseModel:
|
||||
action: int
|
||||
app_name: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict):
|
||||
return cls(
|
||||
action=data["action"],
|
||||
app_name=data["app_name"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinishedModel(BaseModel):
|
||||
exit_code: int
|
||||
playtime: int # seconds
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data):
|
||||
return cls(
|
||||
**BaseModel.from_json(data).__dict__,
|
||||
exit_code=data["exit_code"],
|
||||
playtime=data["playtime"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateChangedModel(BaseModel):
|
||||
class States:
|
||||
started = 0
|
||||
|
||||
# for future
|
||||
syncing_cloud = 1
|
||||
cloud_sync_finished = 2
|
||||
|
||||
new_state: int
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data):
|
||||
return cls(
|
||||
action=data["action"],
|
||||
app_name=data["app_name"],
|
||||
new_state=data["new_state"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorModel(BaseModel):
|
||||
error_string: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data):
|
||||
return cls(
|
||||
**BaseModel.from_json(data).__dict__,
|
||||
error_string=data["error_string"]
|
||||
)
|
|
@ -251,7 +251,28 @@ def get_size(b: int) -> str:
|
|||
b /= 1024
|
||||
|
||||
|
||||
def create_desktop_link(app_name=None, core: LegendaryCore = None, type_of_link="desktop", for_rare: bool = False) -> bool:
|
||||
def get_rare_executable() -> List[str]:
|
||||
if platform.system() == "Linux":
|
||||
# TODO flatpak
|
||||
if p := os.environ.get("APPIMAGE"):
|
||||
executable = [p]
|
||||
else:
|
||||
executable = [sys.executable, os.path.abspath(sys.argv[0])]
|
||||
elif platform.system() == "Windows":
|
||||
executable = [sys.executable]
|
||||
|
||||
if not sys.executable.endswith("Rare.exe"):
|
||||
# be sure to start consoleless then
|
||||
executable[0] = executable[0].replace("python.exe", "pythonw.exe")
|
||||
executable.extend(["-m", "rare"])
|
||||
else: # macos not tested
|
||||
executable = [sys.executable]
|
||||
|
||||
return executable
|
||||
|
||||
|
||||
def create_desktop_link(app_name=None, core: LegendaryCore = None, type_of_link="desktop",
|
||||
for_rare: bool = False) -> bool:
|
||||
if not for_rare:
|
||||
igame = core.get_installed_game(app_name)
|
||||
|
||||
|
@ -276,10 +297,7 @@ def create_desktop_link(app_name=None, core: LegendaryCore = None, type_of_link=
|
|||
return False
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if p := os.environ.get("APPIMAGE"):
|
||||
executable = p
|
||||
else:
|
||||
executable = f"{sys.executable} {os.path.abspath(sys.argv[0])}"
|
||||
executable = get_rare_executable()
|
||||
|
||||
if for_rare:
|
||||
with open(os.path.join(path, "Rare.desktop"), "w") as desktop_file:
|
||||
|
@ -341,9 +359,13 @@ def create_desktop_link(app_name=None, core: LegendaryCore = None, type_of_link=
|
|||
shell = Dispatch("WScript.Shell")
|
||||
shortcut = shell.CreateShortCut(pathLink)
|
||||
|
||||
executable = sys.executable
|
||||
executable = get_rare_executable()
|
||||
arguments = []
|
||||
|
||||
if len(executable) > 1:
|
||||
arguments.extend(executable[1:])
|
||||
executable = executable[0]
|
||||
|
||||
if not sys.executable.endswith("Rare.exe"):
|
||||
# be sure to start consoleless then
|
||||
executable = sys.executable.replace("python.exe", "pythonw.exe")
|
||||
|
|
Loading…
Reference in a new issue