From f0695b32f01ed051d775119093010926a398f983 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Tue, 30 Jan 2024 09:09:17 -0700 Subject: [PATCH 1/7] Add new game state column to game table, and improve install logic. Now games install and after the installtion completes, there is the opportunity to insert some additional logic; eg install was success or not. --- application/alembic/versions/database_v2.py | 4 +- application/alembic/versions/database_v3.py | 39 ++++++++++++++ application/api/v1/blueprints/steam.py | 6 +-- application/common/constants.py | 28 +++++++--- application/games/palworld_game.py | 13 +---- application/gui/launch.py | 3 +- application/gui/widgets/new_game_widget.py | 14 +++++ application/managers/steam_manager.py | 59 +++++++++++++++------ application/models/games.py | 4 ++ 9 files changed, 129 insertions(+), 41 deletions(-) create mode 100644 application/alembic/versions/database_v3.py diff --git a/application/alembic/versions/database_v2.py b/application/alembic/versions/database_v2.py index ae19725..1324721 100644 --- a/application/alembic/versions/database_v2.py +++ b/application/alembic/versions/database_v2.py @@ -1,6 +1,6 @@ -"""Initial Migration. +"""Add support for access tokens -Revision ID: database_v1 +Revision ID: database_v2 Revises: Create Date: 2023-10-08 14:10:31.088339 diff --git a/application/alembic/versions/database_v3.py b/application/alembic/versions/database_v3.py new file mode 100644 index 0000000..c619ac5 --- /dev/null +++ b/application/alembic/versions/database_v3.py @@ -0,0 +1,39 @@ +"""Add updates to the game server table. + +Revision ID: database_v1 +Revises: +Create Date: 2023-10-08 14:10:31.088339 + +""" +from alembic import op +import sqlalchemy as sa + +from application.common.constants import GameStates + +# revision identifiers, used by Alembic. +revision = "database_v3" +down_revision = "database_v2" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("games", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "game_state", + sa.String(length=25), + default=GameStates.NOT_STATE.value, + nullable=False, + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("games", schema=None) as batch_op: + batch_op.drop_column("game_state") + # ### end Alembic commands ### diff --git a/application/api/v1/blueprints/steam.py b/application/api/v1/blueprints/steam.py index f16cf2a..cd33d6b 100644 --- a/application/api/v1/blueprints/steam.py +++ b/application/api/v1/blueprints/steam.py @@ -58,7 +58,7 @@ def steam_app_install(): logger.info("Steam Application has been installed") - return "Success" + return "Success", 200 @steam.route("/steam/app/update", methods=["POST"]) @@ -90,7 +90,7 @@ def steam_app_update(): logger.critical(error) return "Error", 500 - steam_mgr.udpate_steam_app( + steam_mgr.update_steam_app( steam_id, payload["install_dir"], payload["user"], @@ -104,7 +104,7 @@ def steam_app_update(): payload.pop("password") logger.info("Steam Application has been updated") - return "Success" + return "Success", 200 @steam.route("/steam/app/remove", methods=["POST"]) diff --git a/application/common/constants.py b/application/common/constants.py index 936a39d..d743cec 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -3,6 +3,8 @@ from datetime import datetime from enum import Enum +APP_NAME = "AgentSmith" + class DeployTypes(Enum): DOCKER_COMPOSE = "docker_compose" @@ -10,14 +12,25 @@ class DeployTypes(Enum): PYTHON = "python" +class GameStates(Enum): + NOT_STATE = "NO_STATE" + INSTALLING = "installing" + INSTALLED = "installed" + UPDATING = "updating" + UPDATED = "updated" + STARTING = "starting" + STARTED = "started" + STOPPING = "stopping" + STOPPED = "stopped" + RESTARTING = "restarting" + + class FileModes(Enum): NOT_A_FILE = 0 FILE = 1 DIRECTORY = 2 -APP_NAME = "AgentSmith" - if platform.system() == "Windows": DEFAULT_INSTALL_PATH = f"C:\\{APP_NAME}" SSL_FOLDER = f"C:\\{APP_NAME}\\ssl" @@ -27,6 +40,7 @@ class FileModes(Enum): # TODO - Revisit this when linux support gets closer... DEFAULT_INSTALL_PATH = f"/usr/local/share/{APP_NAME}" +# Settings SETTING_NAME_STEAM_PATH: str = "steam_install_dir" SETTING_NAME_DEFAULT_PATH: str = "default_install_dir" SETTING_NAME_APP_SECRET: str = "application_secret" @@ -34,15 +48,17 @@ class FileModes(Enum): SETTING_NGINX_PROXY_HOSTNAME: str = "nginx_proxy_hostname" SETTING_NGINX_ENABLE: str = "nginx_enable" +# Nginx +NGINX_VERSION = "nginx-1.24.0" +NGINX_STABLE_RELEASE_WIN = f"https://nginx.org/download/{NGINX_VERSION}.zip" +# Other / Misc STARTUP_BATCH_FILE_NAME: str = "startup.bat" DEFAULT_SECRET = str(datetime.now()) GAME_INSTALL_FOLDER = "games" - -NGINX_VERSION = "nginx-1.24.0" -NGINX_STABLE_RELEASE_WIN = f"https://nginx.org/download/{NGINX_VERSION}.zip" - LOCALHOST_IP_ADDR = "127.0.0.1" WAIT_FOR_BACKEND: int = 1 +FLASK_SERVER_PORT: int = 5000 + _DeployTypes = DeployTypes diff --git a/application/games/palworld_game.py b/application/games/palworld_game.py index 4af634b..9dbb671 100644 --- a/application/games/palworld_game.py +++ b/application/games/palworld_game.py @@ -19,18 +19,7 @@ def __init__(self, defaults_dict: dict = {}) -> None: self._game_pretty_name = "Palworld" self._game_executable = "PalServer.exe" self._game_steam_id = "2394010" - self._game_info_url = ( - "https://www.gtxgaming.co.uk/palworld-dedicated-server-setup-guide/" - ) - - # if self._game_default_install_dir: - # default_persistent_data_path = os.path.join( - # self._game_default_install_dir, - # constants.GAME_INSTALL_FOLDER, - # self._game_name, - # ) - # else: - # default_persistent_data_path = None + self._game_info_url = "https://tech.palworldgame.com/dedicated-server-guide" # Add Args here, can update later. self._add_argument( diff --git a/application/gui/launch.py b/application/gui/launch.py index d7852b4..988455a 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -40,6 +40,7 @@ def __init__(self, globals_obj: GuiGlobals) -> None: self._installed_games_menu = None self._game_manager_window = None self._globals._global_clipboard = self._gui_app.clipboard() + self._socketio = self._globals._socketio @timeit def _create_backend(self) -> Flask: @@ -57,7 +58,7 @@ def _spawn_server_on_thread(self): self._server_thread = Thread( target=lambda: self._globals._FLASK_APP.run( host="127.0.0.1", # Only accept connections via localhost. - port=5000, + port=constants.FLASK_SERVER_PORT, debug=False, use_reloader=False, threaded=True, diff --git a/application/gui/widgets/new_game_widget.py b/application/gui/widgets/new_game_widget.py index b34ed3f..3656872 100644 --- a/application/gui/widgets/new_game_widget.py +++ b/application/gui/widgets/new_game_widget.py @@ -188,6 +188,20 @@ def _install_game(self, game_pretty_name): constants.SETTING_NAME_STEAM_PATH ) + # Check if game already exists + game_data = self._client.game.get_game_by_name(game_name) + is_game_present = True if len(game_data["items"]) > 0 else False + + # If the game server is already installed, then let the user know. + if is_game_present: + message = QMessageBox() + message.setText( + "Error: That game was already installed! " + "Multiple Same Server installs not yet supported." + ) + message.exec() + return + if install_path == "": message = QMessageBox() message.setText("Error: Must supply an install path. Try again!") diff --git a/application/managers/steam_manager.py b/application/managers/steam_manager.py index fc23a4d..4581c83 100644 --- a/application/managers/steam_manager.py +++ b/application/managers/steam_manager.py @@ -4,6 +4,7 @@ from datetime import datetime from pysteamcmd.steamcmd import Steamcmd from sqlalchemy import exc +from threading import Thread from application import games from application.models.games import Games @@ -28,6 +29,22 @@ def __init__(self, steam_install_dir) -> None: self._steamcmd_exe = self._steam.steamcmd_exe self._steam_install_dir = steam_install_dir + self._install_thread = None + + def _run_install_on_thread(self, steam_id, installation_dir, user, password): + self._install_thread = Thread( + target=lambda: self._install_gamefiles( + gameid=steam_id, + game_install_dir=installation_dir, + user=user, + password=password, + validate=True, + ) + ) + self._install_thread.daemon = True + + self._install_thread.start() + def install_steam_app( self, steam_id, installation_dir, user="anonymous", password=None ): @@ -80,24 +97,12 @@ def install_steam_app( logger.critical(message) raise InvalidUsage(message, status_code=500) - return self._install_gamefiles( - gameid=steam_id, - game_install_dir=installation_dir, - user=user, - password=password, - validate=True, - ) + self._run_install_on_thread(steam_id, installation_dir, user, password) - def udpate_steam_app( + def update_steam_app( self, steam_id, installation_dir, user="anonymous", password=None ): - return self._update_gamefiles( - gameid=steam_id, - game_install_dir=installation_dir, - user=user, - password=password, - validate=True, - ) + self._run_install_on_thread(steam_id, installation_dir, user, password) def _update_gamefiles( self, gameid, game_install_dir, user="anonymous", password=None, validate=False @@ -137,7 +142,27 @@ def _install_gamefiles( library_path = os.path.join(self._steam_install_dir, "linux64") update_environ = os.environ update_environ["LD_LIBRARY_PATH"] = library_path - return subprocess.Popen(steamcmd_params, env=update_environ) + process = subprocess.Popen( + steamcmd_params, + env=update_environ, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) else: # Otherwise, on windows, it's expected that steam is installed. - return subprocess.Popen(steamcmd_params) + process = subprocess.Popen( + steamcmd_params, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + stdout, stderr = process.communicate() + + stdout = stdout.decode("utf-8") + stderr = stderr.decode("utf-8") + + success_msg = f"Success! App '{gameid}' fully installed." + + if success_msg in stdout: + logger.info("The game server successfully installed.") + logger.debug(stdout) + else: + logger.error("Error: The game server did not install properly.") diff --git a/application/models/games.py b/application/models/games.py index 0265b61..b36d421 100644 --- a/application/models/games.py +++ b/application/models/games.py @@ -1,6 +1,7 @@ from datetime import datetime from application.extensions import DATABASE +from application.common.constants import GameStates from application.common.pagination import PaginatedApi @@ -22,6 +23,9 @@ class Games(PaginatedApi, DATABASE.Model): game_last_update = DATABASE.Column( DATABASE.DateTime, default=datetime.utcnow, nullable=False ) + game_state = DATABASE.Column( + DATABASE.String(25), default=GameStates.NOT_STATE.value, nullable=False + ) def to_dict(self): data = {} From 34a275f4270ecc383dbf3b5c0b69971427e1b516 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Tue, 30 Jan 2024 09:43:00 -0700 Subject: [PATCH 2/7] Add toolbox function to update game state. --- application/api/v1/blueprints/steam.py | 16 ++++---- application/common/constants.py | 6 +++ application/common/toolbox.py | 41 ++++++++++++++++++- application/gui/launch.py | 1 - application/managers/steam_manager.py | 54 ++++++++++++++++++++------ 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/application/api/v1/blueprints/steam.py b/application/api/v1/blueprints/steam.py index cd33d6b..dbd8b9c 100644 --- a/application/api/v1/blueprints/steam.py +++ b/application/api/v1/blueprints/steam.py @@ -1,4 +1,5 @@ -from flask import Blueprint, request +from flask import Blueprint, request, current_app + from application.common import logger from application.common.decorators import authorization_required from application.common.exceptions import InvalidUsage @@ -43,12 +44,13 @@ def steam_app_install(): logger.critical(error) return "Error", 500 - steam_mgr.install_steam_app( - steam_id, - payload["install_dir"], - payload["user"], - payload["password"], - ) + with current_app.app_context(): + steam_mgr.install_steam_app( + steam_id, + payload["install_dir"], + payload["user"], + payload["password"], + ) payload.pop("steam_install_path") payload.pop("steam_id") diff --git a/application/common/constants.py b/application/common/constants.py index d743cec..a650857 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -16,13 +16,19 @@ class GameStates(Enum): NOT_STATE = "NO_STATE" INSTALLING = "installing" INSTALLED = "installed" + INSTALL_FAILED = "install_failed" UPDATING = "updating" UPDATED = "updated" + UPDATE_FAILED = "update_failed" STARTING = "starting" STARTED = "started" + STARTUP_FAILED = "startup_failed" STOPPING = "stopping" STOPPED = "stopped" + SHUTDOWN_FAILED = "shutdown_failed" RESTARTING = "restarting" + UNINSTALLING = "uninstalling" + UNINSTALL_FAILED = "uninstall_failed" class FileModes(Enum): diff --git a/application/common/toolbox.py b/application/common/toolbox.py index 8e6c9c4..51b0fc0 100644 --- a/application/common/toolbox.py +++ b/application/common/toolbox.py @@ -4,10 +4,13 @@ import psutil import sys +from application import games from application.common import logger +from application.common.constants import GameStates from application.common.exceptions import InvalidUsage from application.common.game_base import BaseGame -from application import games +from application.extensions import DATABASE +from application.models.games import Games @staticmethod @@ -137,3 +140,39 @@ def get_size(bytes, suffix="B"): if bytes < factor: return f"{bytes:.2f}{unit}{suffix}" bytes /= factor + + +@staticmethod +def update_game_state(game_data: {}, new_state: GameStates) -> True: + update_success = True + game_qry = None + + if "game_id" in game_data: + game_id = game_data["game_id"] + game_qry = Games.query.filter_by(game_id=game_id) + elif "game_steam_id" in game_data and "game_install_dir": + game_steam_id = game_data["game_steam_id"] + game_install_dir = game_data["game_install_dir"] + game_qry = Games.query.filter_by( + game_steam_id=game_steam_id, game_install_dir=game_install_dir + ) + else: + message = "toolbox: update_game_stage: Invalid Imput game_data dictionary" + logger.error(message) + raise Exception(message) + + if len(game_qry.all()) > 1: + message = ( + "toolbox: update_game_stage: Inputs identified two or more games. Error!" + ) + logger.critical(message) + raise Exception(message) + + try: + game_qry.update({"game_state": new_state.value}) + DATABASE.session.commit() + except Exception as error: + logger.critical(error) + update_success = False + + return update_success diff --git a/application/gui/launch.py b/application/gui/launch.py index 988455a..7b9cf1e 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -40,7 +40,6 @@ def __init__(self, globals_obj: GuiGlobals) -> None: self._installed_games_menu = None self._game_manager_window = None self._globals._global_clipboard = self._gui_app.clipboard() - self._socketio = self._globals._socketio @timeit def _create_backend(self) -> Flask: diff --git a/application/managers/steam_manager.py b/application/managers/steam_manager.py index 4581c83..24e7d5e 100644 --- a/application/managers/steam_manager.py +++ b/application/managers/steam_manager.py @@ -1,6 +1,8 @@ import os import subprocess +from flask import current_app, Flask + from datetime import datetime from pysteamcmd.steamcmd import Steamcmd from sqlalchemy import exc @@ -31,9 +33,12 @@ def __init__(self, steam_install_dir) -> None: self._install_thread = None - def _run_install_on_thread(self, steam_id, installation_dir, user, password): + def _run_install_on_thread( + self, current_app, steam_id, installation_dir, user, password + ): self._install_thread = Thread( target=lambda: self._install_gamefiles( + current_app, gameid=steam_id, game_install_dir=installation_dir, user=user, @@ -97,23 +102,33 @@ def install_steam_app( logger.critical(message) raise InvalidUsage(message, status_code=500) - self._run_install_on_thread(steam_id, installation_dir, user, password) + self._run_install_on_thread( + current_app, steam_id, installation_dir, user, password + ) def update_steam_app( self, steam_id, installation_dir, user="anonymous", password=None ): - self._run_install_on_thread(steam_id, installation_dir, user, password) + self._run_install_on_thread( + current_app, steam_id, installation_dir, user, password + ) def _update_gamefiles( self, gameid, game_install_dir, user="anonymous", password=None, validate=False - ): + ) -> bool: return self._install_gamefiles( gameid, game_install_dir, user=user, password=password, validate=validate ) def _install_gamefiles( - self, gameid, game_install_dir, user="anonymous", password=None, validate=False - ): + self, + current_app: Flask, + gameid, + game_install_dir, + user="anonymous", + password=None, + validate=False, + ) -> bool: """ Installs gamefiles for dedicated server. This can also be used to update the gameserver. :param gameid: steam game id for the files downloaded @@ -121,8 +136,10 @@ def _install_gamefiles( :param user: steam username (defaults anonymous) :param password: steam password (defaults None) :param validate: should steamcmd validate the gameserver files (takes a while) - :return: subprocess call to steamcmd + :return: boolean - true if install was sucessful. """ + install_sucesss = True + if validate: validate = "validate" else: @@ -161,8 +178,21 @@ def _install_gamefiles( success_msg = f"Success! App '{gameid}' fully installed." - if success_msg in stdout: - logger.info("The game server successfully installed.") - logger.debug(stdout) - else: - logger.error("Error: The game server did not install properly.") + game_data_dict = {"game_steam_id": gameid, "game_install_dir": game_install_dir} + + with current_app.app_context(): + if success_msg in stdout: + logger.info("The game server successfully installed.") + logger.debug(stdout) + install_sucesss = True + toolbox.update_game_state( + game_data_dict, constants.GameStates.INSTALLED + ) + else: + logger.error("Error: The game server did not install properly.") + install_sucesss = False + toolbox.update_game_state( + game_data_dict, constants.GameStates.INSTALL_FAILED + ) + + return install_sucesss From ac91170ef75ffc5a36af44c2634c64cffb4ab0c9 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Sat, 3 Feb 2024 10:39:59 -0700 Subject: [PATCH 3/7] Implmenent install & update feedback based on thread being alive or not. --- application/api/v1/blueprints/app.py | 10 ++++ application/api/v1/blueprints/steam.py | 37 ++++++++----- application/gui/game_manager_window.py | 3 ++ .../gui/widgets/game_manager_widget.py | 49 +++++++++++++++-- application/gui/widgets/new_game_widget.py | 28 +++++++--- application/managers/steam_manager.py | 53 +++++++------------ 6 files changed, 121 insertions(+), 59 deletions(-) diff --git a/application/api/v1/blueprints/app.py b/application/api/v1/blueprints/app.py index edf3636..7a67d06 100644 --- a/application/api/v1/blueprints/app.py +++ b/application/api/v1/blueprints/app.py @@ -1,3 +1,4 @@ +import threading import sqlalchemy.exc as exc from flask import Blueprint, jsonify, request @@ -12,6 +13,15 @@ app = Blueprint("app", __name__, url_prefix="/v1") +@app.route("/thread/status/", methods=["GET"]) +def is_thread_alive(ident: int): + logger.debug("Checking thread!") + is_alive = any([th for th in threading.enumerate() if th.ident == ident]) + message = f"Thread ID - Still alive: {is_alive}" + logger.debug(message) + return jsonify({"alive": is_alive}) + + class SettingsApi(MethodView): def __init__(self, model): self.model = model diff --git a/application/api/v1/blueprints/steam.py b/application/api/v1/blueprints/steam.py index dbd8b9c..409384b 100644 --- a/application/api/v1/blueprints/steam.py +++ b/application/api/v1/blueprints/steam.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request, current_app +from flask import Blueprint, request, jsonify from application.common import logger from application.common.decorators import authorization_required @@ -44,13 +44,12 @@ def steam_app_install(): logger.critical(error) return "Error", 500 - with current_app.app_context(): - steam_mgr.install_steam_app( - steam_id, - payload["install_dir"], - payload["user"], - payload["password"], - ) + install_thread = steam_mgr.install_steam_app( + steam_id, + payload["install_dir"], + payload["user"], + payload["password"], + ) payload.pop("steam_install_path") payload.pop("steam_id") @@ -60,7 +59,13 @@ def steam_app_install(): logger.info("Steam Application has been installed") - return "Success", 200 + return jsonify( + { + "thread_name": install_thread.name, + "thread_ident": install_thread.native_id, + "activity": "install", + } + ) @steam.route("/steam/app/update", methods=["POST"]) @@ -92,7 +97,7 @@ def steam_app_update(): logger.critical(error) return "Error", 500 - steam_mgr.update_steam_app( + update_thread = steam_mgr.update_steam_app( steam_id, payload["install_dir"], payload["user"], @@ -106,11 +111,19 @@ def steam_app_update(): payload.pop("password") logger.info("Steam Application has been updated") - return "Success", 200 + + return jsonify( + { + "thread_name": update_thread.name, + "thread_ident": update_thread.native_id, + "activity": "install", + } + ) +# TODO - Implement this functionality. @steam.route("/steam/app/remove", methods=["POST"]) @authorization_required def steam_app_remove(): - logger.info("Steam Application has been removed") + logger.info("Remote uninstalls of game servers Not Yet Implemented") return "Success" diff --git a/application/gui/game_manager_window.py b/application/gui/game_manager_window.py index bf1787f..d1ec9ed 100644 --- a/application/gui/game_manager_window.py +++ b/application/gui/game_manager_window.py @@ -63,6 +63,9 @@ def closeEvent(self, event): def showWindow(self): # Wrapping the show method so the timer can be started, if it isn't already in # addition to calling show() + + self._game_manager_widget._disable_all_btns() + self._game_manager_widget.start_timer( override_interval=self._game_manager_widget.FAST_INTERVAL ) diff --git a/application/gui/widgets/game_manager_widget.py b/application/gui/widgets/game_manager_widget.py index 3cac3ad..d639871 100644 --- a/application/gui/widgets/game_manager_widget.py +++ b/application/gui/widgets/game_manager_widget.py @@ -84,9 +84,6 @@ def init_ui(self, game_data): # Get first game in supported games. self.update_installed_games(game_data=game_data, initialize=True) - # Go ahead a run the refresh in case it hasnt run yet. - # self._refresh_on_timer() - # Current game frame gets created in update_installed_games self._layout.addWidget(self._current_game_frame) @@ -94,6 +91,10 @@ def init_ui(self, game_data): self.setLayout(self._layout) + # Initially show all buttons disabled until the logic has a chance to determine the server + # state and show the appropriate set of buttons. + self._disable_all_btns() + self.show() self.start_timer(override_interval=self.FAST_INTERVAL) @@ -209,6 +210,13 @@ def _disable_btn(self, btn: QPushButton): btn.setStyleSheet("text-decoration: line-through;") btn.setEnabled(False) + def _disable_all_btns(self): + self._disable_btn(self._startup_btn) + self._disable_btn(self._uninstall_btn) + self._disable_btn(self._update_btn) + self._disable_btn(self._shutdown_btn) + self._disable_btn(self._restart_btn) + def _build_game_frame(self, game_name): if len(self._installed_supported_games.keys()) == 0: self.update_installed_games() @@ -287,7 +295,7 @@ def _build_game_frame(self, game_name): ) # Game controls - game_control_label = QLabel("Game Controls:", game_frame) + game_control_label = QLabel("Game Server Controls:", game_frame) game_control_label.setStyleSheet("text-decoration: underline;") game_frame_main_layout.addWidget(game_control_label) @@ -393,6 +401,15 @@ def _restart_game(self, game_name): def _update_game(self, game_name): logger.info(f"Updating game: {game_name}") + + message = QMessageBox(self) + message.setWindowTitle("Updating ... ") + message.setText( + "Updating the game server. You will be notified when the process is finished. " + "Please click okay to continue..." + ) + message.exec() + steam_install_dir = self._client.app.get_setting_by_name( constants.SETTING_NAME_STEAM_PATH ) @@ -401,11 +418,33 @@ def _update_game(self, game_name): steam_id = game_info["items"][0]["game_steam_id"] install_path = game_info["items"][0]["game_install_dir"] - self._client.steam.update_steam_app(steam_install_dir, steam_id, install_path) + thread_ident = self._client.steam.update_steam_app( + steam_install_dir, steam_id, install_path + ) + thread_alive = self._client.app.is_thread_alive(thread_ident) + + logger.debug(f"Update Thread Ident: {thread_ident}, Alive: {thread_alive}") + + while thread_alive: + logger.debug("Waiting for update to finish....") + thread_alive = self._client.app.is_thread_alive(thread_ident) + time.sleep(1) + + message = QMessageBox(self) + message.setWindowTitle("Complete") + message.setText("Game Server Update is now complete!") + message.exec() def _uninstall_game(self, game_name): logger.info(f"Uninstall game: {game_name}") + qm = QMessageBox() + response = qm.question(self, "", "Are you sure?", qm.Yes | qm.No) + + if response == qm.No: + logger.debug("_uninstall_game: User opted not to uninstall the game.") + return + # Stop the regular refresh from happening. self.stop_timer() diff --git a/application/gui/widgets/new_game_widget.py b/application/gui/widgets/new_game_widget.py index 3656872..4e6ec22 100644 --- a/application/gui/widgets/new_game_widget.py +++ b/application/gui/widgets/new_game_widget.py @@ -202,6 +202,14 @@ def _install_game(self, game_pretty_name): message.exec() return + message = QMessageBox(self) + message.setWindowTitle("Installing ... ") + message.setText( + f"Installing {game_pretty_name}. You will be notified when the process is finished. " + "Please click okay to continue..." + ) + message.exec() + if install_path == "": message = QMessageBox() message.setText("Error: Must supply an install path. Try again!") @@ -221,18 +229,19 @@ def _install_game(self, game_pretty_name): input_dict[arg] = line_edit.text() - self._client.steam.install_steam_app( + thread_ident = self._client.steam.install_steam_app( steam_install_dir, steam_id, install_path, ) + thread_alive = self._client.app.is_thread_alive(thread_ident) - # Quick sleep. The client functions return while things are running in background - # non-blocking style. - # this is to ensure that the backend added the game record. - # TODO - Consider adding REST API for sole purpose of adding game instaed of doubling up - # with steam intall API. - time.sleep(constants.WAIT_FOR_BACKEND) + logger.debug(f"Install Thread Ident: {thread_ident}, Alive: {thread_alive}") + + while thread_alive: + logger.debug("Waiting for installation to finish....") + thread_alive = self._client.app.is_thread_alive(thread_ident) + time.sleep(1) # Add arguments after install for arg_name, arg_val in input_dict.items(): @@ -251,3 +260,8 @@ def _install_game(self, game_pretty_name): ) self._install_games_menu.update_menu_list() + + message = QMessageBox(self) + message.setWindowTitle("Complete") + message.setText(f"Installation of {game_pretty_name}, complete!") + message.exec() diff --git a/application/managers/steam_manager.py b/application/managers/steam_manager.py index 24e7d5e..dd47c4c 100644 --- a/application/managers/steam_manager.py +++ b/application/managers/steam_manager.py @@ -1,8 +1,6 @@ import os import subprocess -from flask import current_app, Flask - from datetime import datetime from pysteamcmd.steamcmd import Steamcmd from sqlalchemy import exc @@ -31,14 +29,11 @@ def __init__(self, steam_install_dir) -> None: self._steamcmd_exe = self._steam.steamcmd_exe self._steam_install_dir = steam_install_dir - self._install_thread = None - def _run_install_on_thread( - self, current_app, steam_id, installation_dir, user, password - ): - self._install_thread = Thread( + self, steam_id, installation_dir, user, password + ) -> Thread: + sm_thread = Thread( target=lambda: self._install_gamefiles( - current_app, gameid=steam_id, game_install_dir=installation_dir, user=user, @@ -46,13 +41,15 @@ def _run_install_on_thread( validate=True, ) ) - self._install_thread.daemon = True + sm_thread.daemon = True + + sm_thread.start() - self._install_thread.start() + return sm_thread def install_steam_app( self, steam_id, installation_dir, user="anonymous", password=None - ): + ) -> Thread: if not os.path.exists(installation_dir): os.makedirs(installation_dir, mode=0o777, exist_ok=True) @@ -102,16 +99,12 @@ def install_steam_app( logger.critical(message) raise InvalidUsage(message, status_code=500) - self._run_install_on_thread( - current_app, steam_id, installation_dir, user, password - ) + return self._run_install_on_thread(steam_id, installation_dir, user, password) def update_steam_app( self, steam_id, installation_dir, user="anonymous", password=None - ): - self._run_install_on_thread( - current_app, steam_id, installation_dir, user, password - ) + ) -> Thread: + return self._run_install_on_thread(steam_id, installation_dir, user, password) def _update_gamefiles( self, gameid, game_install_dir, user="anonymous", password=None, validate=False @@ -122,7 +115,6 @@ def _update_gamefiles( def _install_gamefiles( self, - current_app: Flask, gameid, game_install_dir, user="anonymous", @@ -178,21 +170,12 @@ def _install_gamefiles( success_msg = f"Success! App '{gameid}' fully installed." - game_data_dict = {"game_steam_id": gameid, "game_install_dir": game_install_dir} - - with current_app.app_context(): - if success_msg in stdout: - logger.info("The game server successfully installed.") - logger.debug(stdout) - install_sucesss = True - toolbox.update_game_state( - game_data_dict, constants.GameStates.INSTALLED - ) - else: - logger.error("Error: The game server did not install properly.") - install_sucesss = False - toolbox.update_game_state( - game_data_dict, constants.GameStates.INSTALL_FAILED - ) + if success_msg in stdout: + logger.info("The game server successfully installed.") + logger.debug(stdout) + install_sucesss = True + else: + logger.error("Error: The game server did not install properly.") + install_sucesss = False return install_sucesss From 7338e444c17be5cff96f1f220c9074a7348ede69 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Sat, 3 Feb 2024 12:32:50 -0700 Subject: [PATCH 4/7] Add quick action menu refresh on interval. --- application/common/constants.py | 1 + application/gui/launch.py | 15 +++++++++++++++ application/gui/widgets/game_manager_widget.py | 5 ++--- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/application/common/constants.py b/application/common/constants.py index a650857..50adeb3 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -65,6 +65,7 @@ class FileModes(Enum): LOCALHOST_IP_ADDR = "127.0.0.1" WAIT_FOR_BACKEND: int = 1 FLASK_SERVER_PORT: int = 5000 +MILIS_PER_SECOND = 1000 _DeployTypes = DeployTypes diff --git a/application/gui/launch.py b/application/gui/launch.py index 7b9cf1e..5a54e64 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -4,6 +4,7 @@ from flask import Flask from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction, QSystemTrayIcon, QMenu, QApplication, QMessageBox +from PyQt5.QtCore import QTimer from threading import Thread from application.config.config import DefaultConfig @@ -22,6 +23,8 @@ class GuiApp: + REFRESH_INTERVAL = 10 * constants.MILIS_PER_SECOND + def __init__(self, globals_obj: GuiGlobals) -> None: # Globals self._globals = globals_obj @@ -41,6 +44,10 @@ def __init__(self, globals_obj: GuiGlobals) -> None: self._game_manager_window = None self._globals._global_clipboard = self._gui_app.clipboard() + # Time to update this class widget + self._timer: QTimer = QTimer() + self._timer.timeout.connect(self._refresh_on_timer) + @timeit def _create_backend(self) -> Flask: config = DefaultConfig("python") @@ -98,6 +105,11 @@ def _launch_settings_widget(self): self._settings_widget.init_ui() self._settings_widget.show() + def _refresh_on_timer(self): + # Refresh the quick actions menu to capture any potential game state changes. + logger.debug("System Tray App: Updating Quick Action Menu.") + self._installed_games_menu.update_menu_list() + def initialize(self, with_server=False): # If running the unified launch script, this will need to start up first. if with_server: @@ -178,4 +190,7 @@ def initialize(self, with_server=False): tray.setContextMenu(self._main_menu) + self._timer.setInterval(self.REFRESH_INTERVAL) + self._timer.start() + self._gui_app.exec_() diff --git a/application/gui/widgets/game_manager_widget.py b/application/gui/widgets/game_manager_widget.py index d639871..1ca2604 100644 --- a/application/gui/widgets/game_manager_widget.py +++ b/application/gui/widgets/game_manager_widget.py @@ -23,9 +23,8 @@ class GameManagerWidget(QWidget): - MILIS_PER_SECOND = 1000 - REFRESH_INTERVAL = 10 * MILIS_PER_SECOND - FAST_INTERVAL = 1 * MILIS_PER_SECOND + REFRESH_INTERVAL = 10 * constants.MILIS_PER_SECOND + FAST_INTERVAL = 1 * constants.MILIS_PER_SECOND def __init__(self, client: Operator, globals, parent: QWidget) -> None: super(QWidget, self).__init__(parent) From ab7d3272a5e7ec7b87342ee5ff5d7362abedcecc Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Sat, 3 Feb 2024 13:53:14 -0700 Subject: [PATCH 5/7] Streamline application startup for faster startup time. --- application/api/controllers/app.py | 32 +++++++++++ application/api/v1/blueprints/app.py | 9 ++- application/common/constants.py | 2 + application/common/decorators.py | 11 ++-- application/common/toolbox.py | 3 + application/gui/game_install_window.py | 2 + application/gui/game_manager_window.py | 2 + application/gui/globals.py | 16 ++++++ application/gui/intalled_games_menu.py | 27 ++++++--- application/gui/launch.py | 55 +++++++++++++------ .../gui/widgets/add_argument_widget.py | 2 + application/gui/widgets/new_game_widget.py | 4 +- application/gui/widgets/nginx_widget.py | 31 +++++++---- application/gui/widgets/settings_widget.py | 27 ++++++--- application/gui/widgets/tokens_widget.py | 24 ++++++-- application/managers/nginx_manager.py | 35 +++++++----- 16 files changed, 210 insertions(+), 72 deletions(-) create mode 100644 application/api/controllers/app.py diff --git a/application/api/controllers/app.py b/application/api/controllers/app.py new file mode 100644 index 0000000..2b1de9f --- /dev/null +++ b/application/api/controllers/app.py @@ -0,0 +1,32 @@ +from flask import jsonify + +from application.models.games import Games +from application.models.settings import Settings +from application.models.tokens import Tokens + + +def get_startup_data(): + startup_data = {} + game_server_list = {} + token_list = {} + settings_list = {} + + # All Games + all_games = Games.query.all() + game_server_list = [x.to_dict() for x in all_games] + + # All Active Tokens + all_active_tokens = Tokens.query.filter_by(token_active=True).all() + token_list = [x.to_dict() for x in all_active_tokens] + + # All Settings + all_settings = Settings.query.all() + settings_list = [x.to_dict() for x in all_settings] + + startup_data = { + "games": game_server_list, + "tokens": token_list, + "settings": settings_list, + } + + return jsonify(startup_data) diff --git a/application/api/v1/blueprints/app.py b/application/api/v1/blueprints/app.py index 7a67d06..14d32ab 100644 --- a/application/api/v1/blueprints/app.py +++ b/application/api/v1/blueprints/app.py @@ -4,11 +4,12 @@ from flask import Blueprint, jsonify, request from flask.views import MethodView -from application.models.settings import Settings +from application.api.controllers import app as app_controller from application.common import logger from application.common.decorators import authorization_required from application.common.exceptions import InvalidUsage from application.extensions import DATABASE +from application.models.settings import Settings app = Blueprint("app", __name__, url_prefix="/v1") @@ -22,6 +23,12 @@ def is_thread_alive(ident: int): return jsonify({"alive": is_alive}) +@app.route("/gui/startup", methods=["GET"]) +@authorization_required +def get_startup_data(): + return app_controller.get_startup_data() + + class SettingsApi(MethodView): def __init__(self, model): self.model = model diff --git a/application/common/constants.py b/application/common/constants.py index 50adeb3..6f7e69b 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -67,5 +67,7 @@ class FileModes(Enum): FLASK_SERVER_PORT: int = 5000 MILIS_PER_SECOND = 1000 +# Controls not meant to be hooked up to GUI. Just for dev/debug: +ENABLE_TIMEIT_PRINTS = False _DeployTypes = DeployTypes diff --git a/application/common/decorators.py b/application/common/decorators.py index 101769d..cad32f4 100644 --- a/application/common/decorators.py +++ b/application/common/decorators.py @@ -3,7 +3,7 @@ from time import time from application.common import logger -from application.common.constants import LOCALHOST_IP_ADDR +from application.common.constants import LOCALHOST_IP_ADDR, ENABLE_TIMEIT_PRINTS from application.common.authorization import _verify_bearer_token @@ -72,10 +72,11 @@ def wrap(*args, **kw): ts = time() result = f(*args, **kw) te = time() - logger.debug( - "Function: :%r args:[%r, %r] took: %2.4f sec" - % (f.__name__, args, kw, te - ts) - ) + if ENABLE_TIMEIT_PRINTS: + logger.debug( + "Function: :%r args:[%r, %r] took: %2.4f sec" + % (f.__name__, args, kw, te - ts) + ) return result return wrap diff --git a/application/common/toolbox.py b/application/common/toolbox.py index 51b0fc0..c031032 100644 --- a/application/common/toolbox.py +++ b/application/common/toolbox.py @@ -7,6 +7,7 @@ from application import games from application.common import logger from application.common.constants import GameStates +from application.common.decorators import timeit from application.common.exceptions import InvalidUsage from application.common.game_base import BaseGame from application.extensions import DATABASE @@ -53,6 +54,7 @@ def get_resources_dir(this_file) -> str: @staticmethod +@timeit def _find_conforming_modules(package) -> {}: package_location = package.__path__ @@ -85,6 +87,7 @@ def _find_conforming_modules(package) -> {}: @staticmethod +@timeit def _instantiate_object(module_name, module, defaults_dict={}): return_obj = None for item in inspect.getmembers(module, inspect.isclass): diff --git a/application/gui/game_install_window.py b/application/gui/game_install_window.py index b889268..7d58f64 100644 --- a/application/gui/game_install_window.py +++ b/application/gui/game_install_window.py @@ -7,11 +7,13 @@ ) from PyQt5.QtGui import QIcon +from application.common.decorators import timeit from application.gui.globals import GuiGlobals from application.gui.widgets.new_game_widget import NewGameWidget class GameInstallWindow(QMainWindow): + @timeit def __init__(self, globals: GuiGlobals): super().__init__() self.title = "Install New Game Server" diff --git a/application/gui/game_manager_window.py b/application/gui/game_manager_window.py index d1ec9ed..9a095d6 100644 --- a/application/gui/game_manager_window.py +++ b/application/gui/game_manager_window.py @@ -11,11 +11,13 @@ ) from PyQt5.QtGui import QIcon +from application.common.decorators import timeit from application.gui.globals import GuiGlobals from application.gui.widgets.game_manager_widget import GameManagerWidget class GameManagerWindow(QMainWindow): + @timeit def __init__(self, globals: GuiGlobals): super().__init__() self.title = "Game Manager" diff --git a/application/gui/globals.py b/application/gui/globals.py index bf804f0..d82adb2 100644 --- a/application/gui/globals.py +++ b/application/gui/globals.py @@ -21,6 +21,9 @@ def __init__(self): self._server_port: str = "5000" self._steam_install_path: str = "NOT_SET" self._default_install_path: str = "NOT_SET" + self._init_settings_data: dict = {} + self._init_tokens_data: dict = {} + self._init_games_data: dict = {} # Objects self._FLASK_APP: Flask = None @@ -29,3 +32,16 @@ def __init__(self): self._add_arguments_widget: AddArgumentWidget = None self._global_clipboard: QClipboard = None self._nginx_manager: NginxManager = None + + def set_initialization_data(self, input_data: {}): + settings = input_data["settings"] + tokens = input_data["tokens"] + games = input_data["games"] + + settings_dict = {} + for item in settings: + settings_dict[item["setting_name"]] = item["setting_value"] + + self._init_settings_data = settings_dict + self._init_games_data = games + self._init_tokens_data = tokens diff --git a/application/gui/intalled_games_menu.py b/application/gui/intalled_games_menu.py index 058f194..2224b8b 100644 --- a/application/gui/intalled_games_menu.py +++ b/application/gui/intalled_games_menu.py @@ -2,8 +2,9 @@ from PyQt5.QtWidgets import QMenu, QWidget, QWidgetAction, QPushButton -from application.common import toolbox, logger from application import games +from application.common import toolbox, logger +from application.common.decorators import timeit from operator_client import Operator BACKGROUND_STR = "background-color: {color}; padding: 8 8 8 8px;" @@ -12,27 +13,33 @@ class InstalledGameMenu(QMenu): - def __init__(self, parent: QWidget, client: Operator) -> None: + @timeit + def __init__(self, parent: QWidget, client: Operator, init_data: dict) -> None: super(InstalledGameMenu, self).__init__("Quick Start/Stop", parent=parent) self._client = client self._parent = parent + self._init_data = init_data self._buttons: dict = {} self._modules_dict = toolbox._find_conforming_modules(games) # Must call update_menu_list as opposed to update_menu to avoid overloading built in # function name! - self.update_menu_list() + self.update_menu_list(initialize=True) - def update_menu_list(self, delay_sec=0): + @timeit + def update_menu_list(self, initialize=True, delay_sec=0): if delay_sec > 0: time.sleep(delay_sec) self.clear() self._buttons.clear() - all_games = self._client.game.get_games() - all_games = all_games["items"] + if initialize: + all_games = self._init_data + else: + all_games = self._client.game.get_games() + all_games = all_games["items"] for game in all_games: game_name = game["game_name"] @@ -44,9 +51,13 @@ def update_menu_list(self, delay_sec=0): action = QWidgetAction(self) button = QPushButton(game_pretty_name) - if self._is_running(game_pid) and self._executable_is_found(game_exe): + + is_game_running = self._is_running(game_pid) + is_exe_found = self._executable_is_found(game_exe) + + if is_game_running and is_exe_found: button.setStyleSheet(BACKGROUND_STR.format(color=COLOR_RUNNING)) - elif not self._is_running(game_pid) and self._executable_is_found(game_exe): + elif not is_game_running and is_exe_found: button.setStyleSheet(BACKGROUND_STR.format(color=COLOR_RUNNING)) else: button.setStyleSheet(BACKGROUND_STR.format(color=COLOR_STOPPED)) diff --git a/application/gui/launch.py b/application/gui/launch.py index 5a54e64..d0cd83c 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -23,7 +23,7 @@ class GuiApp: - REFRESH_INTERVAL = 10 * constants.MILIS_PER_SECOND + REFRESH_INTERVAL = 60 * constants.MILIS_PER_SECOND def __init__(self, globals_obj: GuiGlobals) -> None: # Globals @@ -76,10 +76,11 @@ def _spawn_server_on_thread(self): self._server_thread.start() def quit_gui(self): - self._globals._nginx_manager.shtudown() + self._globals._nginx_manager.shutdown() self._gui_app.quit() def _launch_game_manager_window(self): + # This has to be the current games, not init data. games = self._globals._client.game.get_games() if len(games["items"]) == 0: @@ -112,31 +113,51 @@ def _refresh_on_timer(self): def initialize(self, with_server=False): # If running the unified launch script, this will need to start up first. + + initialization_data = None + if with_server: # Launch Flask Server self._spawn_server_on_thread() - nginx_enable = self._globals._client.app.get_setting_by_name( - constants.SETTING_NGINX_ENABLE + # Give server a chance to start before proceeding... + time.sleep(1) + + initialization_data = self._globals._client.app.get_gui_initialization_data() + + if initialization_data is None: + message = QMessageBox() + message.setText( + "Error: The backend Agent Software is not responding. Unable to start up." ) + message.exec() + return - # DB Stores as string so quick conversion to boolean. - nginx_enable = True if nginx_enable == "1" else False + self._globals.set_initialization_data(initialization_data) - # Generate SSL key pair if non-existant. - if not self._globals._nginx_manager.key_pair_exists(): - self._globals._nginx_manager.generate_ssl_certificate() + nginx_enable = self._globals._init_settings_data[constants.SETTING_NGINX_ENABLE] - # Launch Reverse Proxy Server if enabled. - if nginx_enable: - self._globals._nginx_manager.startup() + # DB Stores as string so quick conversion to boolean. + nginx_enable = True if nginx_enable == "1" else False - # Give server a chance to start before proceeding... - time.sleep(1) + # Generate SSL key pair if non-existant. + if not self._globals._nginx_manager.key_pair_exists(): + logger.debug( + "Warning: No nginx key pair found, creating a new SSL certificate pair." + ) + self._globals._nginx_manager.generate_ssl_certificate( + initialize=self._globals._init_settings_data + ) + + # Launch Reverse Proxy Server if enabled. + if nginx_enable: + self._globals._nginx_manager.startup( + initialize=self._globals._init_settings_data + ) # Instantiate this last always! self._installed_games_menu = InstalledGameMenu( - self._main_menu, self._globals._client + self._main_menu, self._globals._client, self._globals._init_games_data ) self._add_arguments_widget = AddArgumentWidget(self._globals._client) @@ -190,7 +211,7 @@ def initialize(self, with_server=False): tray.setContextMenu(self._main_menu) - self._timer.setInterval(self.REFRESH_INTERVAL) - self._timer.start() + # self._timer.setInterval(self.REFRESH_INTERVAL) + # self._timer.start() self._gui_app.exec_() diff --git a/application/gui/widgets/add_argument_widget.py b/application/gui/widgets/add_argument_widget.py index 2ef5c1d..3b163b2 100644 --- a/application/gui/widgets/add_argument_widget.py +++ b/application/gui/widgets/add_argument_widget.py @@ -13,11 +13,13 @@ from PyQt5.QtCore import Qt from application.common.constants import FileModes +from application.common.decorators import timeit from application.gui.widgets.file_select_widget import FileSelectWidget from operator_client import Operator class AddArgumentWidget(QWidget): + @timeit def __init__(self, client: Operator, parent: QWidget = None) -> None: super(QWidget, self).__init__(parent) diff --git a/application/gui/widgets/new_game_widget.py b/application/gui/widgets/new_game_widget.py index 4e6ec22..a5383ea 100644 --- a/application/gui/widgets/new_game_widget.py +++ b/application/gui/widgets/new_game_widget.py @@ -48,9 +48,9 @@ def __init__(self, globals: GuiGlobals, parent: QWidget): self._client, constants.FileModes.DIRECTORY, self ) - self._default_install_dir: str = self._client.app.get_setting_by_name( + self._default_install_dir: str = self._globals._init_settings_data[ constants.SETTING_NAME_DEFAULT_PATH - ) + ] self._defaults: dict = { constants.SETTING_NAME_DEFAULT_PATH: self._default_install_dir diff --git a/application/gui/widgets/nginx_widget.py b/application/gui/widgets/nginx_widget.py index c069325..a639b71 100644 --- a/application/gui/widgets/nginx_widget.py +++ b/application/gui/widgets/nginx_widget.py @@ -11,6 +11,7 @@ from PyQt5.QtGui import QClipboard from application.common import constants +from application.common.decorators import timeit from application.gui.widgets.nginx_cert_viewer_widget import NginxCertViewer from application.managers.nginx_manager import NginxManager @@ -18,12 +19,14 @@ class NginxWidget(QWidget): + @timeit def __init__( self, client: Operator, clipboard: QClipboard, nginx_manager: NginxManager, parent: QWidget = None, + init_data=None, ): super(QWidget, self).__init__(parent) @@ -31,6 +34,7 @@ def __init__( self._client = client self._clipboard = clipboard self._nginx_manager = nginx_manager + self._init_data = init_data self._initialized = False self._nginx_enable_checkbox: QCheckBox = None @@ -58,8 +62,9 @@ def _create_nginx_controls(self) -> QVBoxLayout: label = QLabel("Nginx On/Off") self._nginx_enable_checkbox = QCheckBox() - # TODO - SEt this on off based on current state. - if self._nginx_manager.is_running(): + is_running = self._nginx_manager.is_running() + + if is_running: self._nginx_enable_checkbox.setChecked(True) self._nginx_enable_checkbox.toggled.connect(self._handle_nginx_enable_checkbox) @@ -68,12 +73,18 @@ def _create_nginx_controls(self) -> QVBoxLayout: h_layout.addWidget(self._nginx_enable_checkbox) # Port / Host / Other Settings - nginx_proxy_port = self._client.app.get_setting_by_name( - constants.SETTING_NGINX_PROXY_PORT - ) - nginx_proxy_hostname = self._client.app.get_setting_by_name( - constants.SETTING_NGINX_PROXY_HOSTNAME - ) + if self._init_data: + nginx_proxy_port = self._init_data[constants.SETTING_NGINX_PROXY_PORT] + nginx_proxy_hostname = self._init_data[ + constants.SETTING_NGINX_PROXY_HOSTNAME + ] + else: + nginx_proxy_port = self._client.app.get_setting_by_name( + constants.SETTING_NGINX_PROXY_PORT + ) + nginx_proxy_hostname = self._client.app.get_setting_by_name( + constants.SETTING_NGINX_PROXY_HOSTNAME + ) h2_layout = QHBoxLayout() label = QLabel("Nginx Hostname: ") @@ -105,7 +116,7 @@ def _create_nginx_controls(self) -> QVBoxLayout: v_control_layout.addWidget(self._view_public_cert) # Don't let someone edit the settings when nginx is running. - if self._nginx_manager.is_running(): + if is_running: self._disable_controls() return v_control_layout @@ -132,7 +143,7 @@ def _handle_nginx_enable_checkbox(self): self._nginx_manager.startup() self._disable_controls() else: - self._nginx_manager.shtudown() + self._nginx_manager.shutdown() self._enable_controls() def _handle_nginx_hostname_edit(self, text): diff --git a/application/gui/widgets/settings_widget.py b/application/gui/widgets/settings_widget.py index 959278c..59dcadc 100644 --- a/application/gui/widgets/settings_widget.py +++ b/application/gui/widgets/settings_widget.py @@ -9,6 +9,7 @@ from PyQt5.QtCore import Qt from application.common import constants, logger +from application.common.decorators import timeit from application.gui.globals import GuiGlobals from application.gui.widgets.file_select_widget import FileSelectWidget from application.gui.widgets.nginx_widget import NginxWidget @@ -18,6 +19,7 @@ class SettingsWidget(QWidget): + @timeit def __init__(self, client: Operator, globals: GuiGlobals, parent: QWidget = None): super(QWidget, self).__init__(parent) self._layout = QVBoxLayout() @@ -26,12 +28,19 @@ def __init__(self, client: Operator, globals: GuiGlobals, parent: QWidget = None self._initialized = False self._token_widget = TokensWidget( - self._client, self._globals._global_clipboard, self + self._client, + self._globals._global_clipboard, + parent=self, + init_data=self._globals._init_tokens_data, ) self._nginx_manager: NginxManager = self._globals._nginx_manager self._nginx_widget = NginxWidget( - self._client, self._globals._global_clipboard, self._nginx_manager, self + self._client, + self._globals._global_clipboard, + self._nginx_manager, + parent=self, + init_data=self._globals._init_settings_data, ) self.setWindowTitle("App Settings") @@ -39,15 +48,15 @@ def __init__(self, client: Operator, globals: GuiGlobals, parent: QWidget = None def init_ui(self): self._layout.setAlignment(Qt.AlignTop) - steam_install_dir = self._client.app.get_setting_by_name( + steam_install_dir = self._globals._init_settings_data[ constants.SETTING_NAME_STEAM_PATH - ) - default_install_dir = self._client.app.get_setting_by_name( + ] + default_install_dir = self._globals._init_settings_data[ constants.SETTING_NAME_DEFAULT_PATH - ) - # application_secret = self._client.app.get_setting_by_name( - # constants.SETTING_NAME_APP_SECRET - # ) + ] + + # Add back in later if needed - Comment out for now. + # application_secret = self._globals._init_settings_data[constants.SETTING_NAME_APP_SECRET] self._globals._steam_install_path = steam_install_dir diff --git a/application/gui/widgets/tokens_widget.py b/application/gui/widgets/tokens_widget.py index a40f164..96b9650 100644 --- a/application/gui/widgets/tokens_widget.py +++ b/application/gui/widgets/tokens_widget.py @@ -11,15 +11,24 @@ from PyQt5.QtGui import QClipboard from application.common import logger +from application.common.decorators import timeit from operator_client import Operator class TokensWidget(QWidget): - def __init__(self, client: Operator, clipboard: QClipboard, parent: QWidget = None): + @timeit + def __init__( + self, + client: Operator, + clipboard: QClipboard, + parent: QWidget = None, + init_data=None, + ): super(QWidget, self).__init__(parent) self._layout = QVBoxLayout() self._client = client self._clipboard = clipboard + self._init_data = init_data self._initialized = False self._new_token_name: QLineEdit = None @@ -34,7 +43,7 @@ def init_ui(self): self._layout.addLayout(self._create_generate_token()) self._layout.addLayout(self._create_newly_generated_token()) - self._current_tokens = self._build_token_frame() + self._current_tokens = self._build_token_frame(initialize=True) self._layout.addWidget(self._current_tokens) self._newly_generated_token.hide() @@ -75,10 +84,13 @@ def _create_token_entry(self, token_name): return h_layout - def _create_tokens_vbox(self) -> QVBoxLayout: + def _create_tokens_vbox(self, initialize=False) -> QVBoxLayout: v_layout = QVBoxLayout() - all_tokens = self._client.access.get_all_active_tokens() + if initialize: + all_tokens = self._init_data + else: + all_tokens = self._client.access.get_all_active_tokens() if len(all_tokens) == 0: v_layout.addWidget(QLabel("No Tokens Yet!")) @@ -94,9 +106,9 @@ def _create_tokens_vbox(self) -> QVBoxLayout: return v_layout - def _build_token_frame(self): + def _build_token_frame(self, initialize=False): token_frame = QFrame() - token_frame.setLayout(self._create_tokens_vbox()) + token_frame.setLayout(self._create_tokens_vbox(initialize=initialize)) token_frame.setFrameStyle(QFrame.StyledPanel | QFrame.Plain) token_frame.setLineWidth(1) diff --git a/application/managers/nginx_manager.py b/application/managers/nginx_manager.py index a15c155..767711d 100644 --- a/application/managers/nginx_manager.py +++ b/application/managers/nginx_manager.py @@ -62,10 +62,13 @@ def remove_ssl_key_pair(self) -> None: if os.path.exists(constants.SSL_CERT_FILE): os.remove(constants.SSL_CERT_FILE) - def generate_ssl_certificate(self) -> None: - nginx_proxy_hostname = self._client.app.get_setting_by_name( - constants.SETTING_NGINX_PROXY_HOSTNAME - ) + def generate_ssl_certificate(self, initialize=None) -> None: + if initialize: + nginx_proxy_hostname = initialize[constants.SETTING_NGINX_PROXY_HOSTNAME] + else: + nginx_proxy_hostname = self._client.app.get_setting_by_name( + constants.SETTING_NGINX_PROXY_HOSTNAME + ) validityEndInSeconds = 365 * 24 * 60 * 60 # One Year @@ -126,12 +129,12 @@ def generate_ssl_certificate(self) -> None: crypto.dump_privatekey(crypto.FILETYPE_PEM, pub_key).decode("utf-8") ) - def startup(self) -> None: + def startup(self, initialize=None) -> None: if self.is_running(): self._stop_nginx() - self._spawn_nginx() + self._spawn_nginx(initialize=initialize) - def shtudown(self) -> None: + def shutdown(self) -> None: if self._stop_nginx(): self._exe_thread.join() @@ -241,16 +244,20 @@ def _download_nginx_server(self): zip_ref.extractall(nginx_folder_path) @timeit - def _spawn_nginx(self): + def _spawn_nginx(self, initialize=None): self._download_nginx_server() - nginx_proxy_hostname = self._client.app.get_setting_by_name( - constants.SETTING_NGINX_PROXY_HOSTNAME - ) + if initialize: + nginx_proxy_hostname = initialize[constants.SETTING_NGINX_PROXY_HOSTNAME] + nginx_proxy_port = initialize[constants.SETTING_NGINX_PROXY_PORT] + else: + nginx_proxy_hostname = self._client.app.get_setting_by_name( + constants.SETTING_NGINX_PROXY_HOSTNAME + ) - nginx_proxy_port = self._client.app.get_setting_by_name( - constants.SETTING_NGINX_PROXY_PORT - ) + nginx_proxy_port = self._client.app.get_setting_by_name( + constants.SETTING_NGINX_PROXY_PORT + ) nginx_folder = os.path.join( constants.DEFAULT_INSTALL_PATH, "nginx", constants.NGINX_VERSION From 673ec0f3462130402e7aa5acc5922888ee286205 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Sat, 3 Feb 2024 16:20:03 -0700 Subject: [PATCH 6/7] Fix an initialization bug and add design docs. --- README.md | 47 +++------- agent-smith.spec | 2 +- application/gui/intalled_games_menu.py | 2 +- application/gui/launch.py | 4 +- docs/design.md | 114 +++++++++++++++++++++++++ package.py | 55 +++++++----- 6 files changed, 164 insertions(+), 60 deletions(-) create mode 100644 docs/design.md diff --git a/README.md b/README.md index fa66fde..0e17cc7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ Agent Smith is a self-contained System Tray Application software with Flask Serv an API, written in Python. The GUI built with PyQT and uses the [Operator](https://github.com/agentsofthesystem/operator) client to communicate with the server. - ### Features Some of Agent Smith's core features are: @@ -37,50 +36,28 @@ written a whole [section on it](./docs/security.md). The author takes security wants you to be informed about the risks and what's been done to make this software as safe as possible. +# Design + +There is a dedicated section regarding the [design](./docs/design.md) of Agent Smith. For a detailed +account of system design, explanations for design choices, limitations, and more, please have a look +at that section. + # Future Work -I have identified work I intend to do for the future and could use help with in the "Future Work" -section. Please create an issue for ideas. I also wanted to share, what I don't intend to do as -well so that is also clear, please see the "Author's areas of dis-interest" section for that. +I have identified work I intend to do for the future and could use help with. However, if you have +an idea yourself, please create an issue for that. There will always be room for improvements, but here are some high level goals the author has to improve behavior and usability overall: -1. Get unit test framework to minimal code coverage; 20%. +1. Get functional & unit test framework to minimal code coverage; 20%. 2. Cross-platform support - I'd like to be able to support Linux Distributions as well. 3. Support steam game versions; eg install latest_experiemental or a specific release. -# Limitations - -The software has some limitations that users ought to be aware of. - -1. The steamcmd client that downloads a game server (based on its steam_id) does so as an anonymous user. If it - doesn't download publically, it's not supported very well. The package I used addresses this but I haven't paid - much attention to it. If your needs require authentication, be prepared for some issues. -2. Windows firewall: This software cannot click the button that says "Allow this app through the firewall". -3. Port Forwarding: This software cannot set up port forwarding rules on any network hardware -4. Linux: To start, the agent software is intended to operate on Windows. However, the client can run on windows or - linux. - -# Author's areas of dis-interest - -In this section, the author is attempting to describe what he personally is not interested in working. - -1. I want to add the games that I want to manage into the software. I tried to go for an interface that is genearlized - so I didn't have to build customizations for each game server. Therefore, if you as a user want a game added. - Be prepared to contribute! -2. The Graphical User Interface: To be honest, I'm not a GUI person. It's not a pretty GUI but it gets the job done. - I will help with bug-related issues, I'm not going to personally work issues to enhance the GUI unless I feel - strongly about it. I'm not opposed to making it better. If someone wants to make it better go for it! -3. Supporting new features so you can use this for personal gain. Again, if you do this, you must share the code back - for the community, but I'm not going to be helping. -4. Building any support to upload and run generic executables via API. That is a **HUGE** security risk - I don't want this software to put on any user. - # References / Acknowledgements 1. For packaging up and making an installer in the future - https://pyinstaller.org/en/stable/index.html 2. SteamCMD Documentation - https://developer.valvesoftware.com/wiki/SteamCMD -3. All other dependencies, have a look at [requirements.txt](./requirements.txt). -4. I found pythonsteamcmd here - https://github.com/f0rkz/pysteamcmd and it was helpful so I did not - have to rebuild an interface to steamcmd. \ No newline at end of file +3. I found pythonsteamcmd here - https://github.com/f0rkz/pysteamcmd and it was helpful so I did not + have to rebuild an interface to steamcmd. +4. All other dependencies, have a look at [requirements.txt](./requirements.txt). \ No newline at end of file diff --git a/agent-smith.spec b/agent-smith.spec index 45e59da..2a9f7ec 100644 --- a/agent-smith.spec +++ b/agent-smith.spec @@ -28,7 +28,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=True, + console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/application/gui/intalled_games_menu.py b/application/gui/intalled_games_menu.py index 2224b8b..0fa48aa 100644 --- a/application/gui/intalled_games_menu.py +++ b/application/gui/intalled_games_menu.py @@ -28,7 +28,7 @@ def __init__(self, parent: QWidget, client: Operator, init_data: dict) -> None: self.update_menu_list(initialize=True) @timeit - def update_menu_list(self, initialize=True, delay_sec=0): + def update_menu_list(self, initialize=False, delay_sec=0): if delay_sec > 0: time.sleep(delay_sec) diff --git a/application/gui/launch.py b/application/gui/launch.py index d0cd83c..99227ba 100644 --- a/application/gui/launch.py +++ b/application/gui/launch.py @@ -211,7 +211,7 @@ def initialize(self, with_server=False): tray.setContextMenu(self._main_menu) - # self._timer.setInterval(self.REFRESH_INTERVAL) - # self._timer.start() + self._timer.setInterval(self.REFRESH_INTERVAL) + self._timer.start() self._gui_app.exec_() diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..851939b --- /dev/null +++ b/docs/design.md @@ -0,0 +1,114 @@ +# Design + +This section of the documentation is intended to capture how Agent Smith works, explanations for +certain design choices, limitations, and + +## Architecture + +Explanations regarding architecture are contained in this section. + +### Generalized + +In short, Agent Smith is a web server with a PyQt front end for the user. The web server sports +an API that allows a client to make requests. There is a go-between piece of software called, +[Operator](https://github.com/agentsofthesystem/operator), and both the PyQt GUI uses it and anyone +wishing to write their own script. Everything is python based with the singular exception of Ningx. +For an explanation why, please see the explanations section. + +### API + +Application Programming Interface (API) + +The api is broken into versions in the [API](../application/api/) folder. The API itself is +versioned; v1, v2, etc... Flask itself uses a conecpt of a "Blueprint" to break them out so each +logical section of API is broken into its own Blueprint. For example, the games blueprint contains +the games API, and its responsible for manipulating Game Servers. + +### Security + +When running Agent Smith in production, the backend web server intenitally blocks outside connections +that do not come from localhost. Incoming web connections would go through Nginx, and then are reversed +proxied back to Agent Smith. Therefore, in production, from AgentSmith's perspective all web requests +are coming from localhost. + +In reality, web requests may also come from a different IP address, so the inner software also +enforces tokenized security based on the IP address. Nginx is configured to proxy pass the requestor's +IP address. Whenever AgentSmith see's a request that came from an origin IP address that is not +localhost, then it will expect a valid Bearer Token. But if the request did come from localhost, +specifically the PyQt GUI, then the backend web server will allow the connection without the token. + +More about tokens. The security tokens may be generated via the Operator client, or via GUI. Once +the token is generated the user gets a singular chance to copy it. The token itself is a Java Web +Token (JWT) based token. + +External web requests must use SSL through Nginx and have a valid token in the request header. The +Operator client makes this seamless to the user. + +Finally, a word about flask. Flask has a console made to run debug commands on. That console is +intentionally disabled. Running flask in debug mode should never be done by altering the code in +[config.py](../application/config/config.py) where "DEBUG=True". + +## Game Server Framework + +Game Servers are built into Agent Smith via a framework. One will notice the [games](../application/games/) +folder contains implementations of the supported Game Servers that are available. All of them inherit +from the [BaseGame](../application/common/game_base.py) class. + +To add a new supported dedicated game server one adds a new class which implements all the abstract +functions; startup, shutdown. The rest of the software dynamically imports the games module and +has the ability to generate the GUI on the fly. That way someone can add a new game and not have to +worry about updating the GUI. + +## Explanations + +* **Why are some things implemented with Threading?** - The author is proficient in what asynchronous + background task execution is and how that is implemented. In python, that's typically implemented + using a package called Celery, however, it's intentionally not being used for the purpose of keeping + the software used to being python only. The author wanted users to only have to download an EXE + file and not have to install pre-requisite softare, such as a message queue server. + +* **Why only supported games and not the ability to run scripts or generic executables?** - Firstly, + security. By not allowing scripts and generic executables to be run security is greatly enhanced. + Next, each and every single game is different. Some required specific arguments while others need + an input file. Also, some game servers don't just simply shutdown by killing the server executable, + and need extra steps to avoid corrupting files. Implementing a framework allows some semblance of + order to the disorder which is the dedicated game server landscape. + + +## Limitations + +The software has some limitations that users ought to be aware of. + +1. The steamcmd client that downloads a game server (based on its steam_id) does so as an anonymous user. If it + doesn't download publically, it's not supported very well. The package I used addresses this but I haven't paid + much attention to it. If your needs require authentication, be prepared for some issues. +2. Windows firewall: This software cannot click the button that says "Allow this app through the firewall". +3. Port Forwarding: This software cannot set up port forwarding rules on any network hardware +4. Linux: To start, the agent software is intended to operate on Windows. However, the client can run on windows or + linux. + +## Design - Dead Ends + +In this section, the author is attempting to describe both what the author personally has no interest +in working on, and "features" that Agent Smith should never adopt. All of these items would be +considered dead ends. + +### Authtor's own lack of interest + +1. I want to add the games that I want to manage into the software. I tried to go for an interface that is genearlized + so I didn't have to build customizations for each game server. Therefore, if you as a user want a game added that + the author isn't crazy about playingm, then be prepared to contribute! +2. The Graphical User Interface: To be honest, I'm not a GUI person. It's not a pretty GUI but it gets the job done. + I will help with bug-related issues, I'm not going to personally work issues to enhance the GUI unless I feel + strongly about it. I'm not opposed to making it better. If someone wants to make it better go for it! +3. Supporting new features so you can use this for personal gain. Again, if you do this, you must share the code back + for the community, but I'm not going to be helping. + +### Dead Ends + +* Building any support to upload and run generic executables via API. That is a **HUGE** security + risk I don't want this software to put on any user. +* Adding anything other than python & nginx to the tech stack. This software is intended to be + lean. +* By-passing security measures. No feature reducting the security posture of this tool should be + accepted. \ No newline at end of file diff --git a/package.py b/package.py index 038969f..df2424d 100644 --- a/package.py +++ b/package.py @@ -1,35 +1,48 @@ +import sys import PyInstaller.__main__ from sys import platform -def main(): +def main(debug=False): # On windows, the separator is a semi-colon. On linux it's a colon. if platform == "win32": sep = ";" else: sep = ":" - PyInstaller.__main__.run( - [ - "agent-smith.py", - "--onefile", - "--icon=./application/gui/resources/agent-black.ico", - f"--add-data=./application/config/nginx/*{sep}./application/config/nginx", # noqa: E501 - f"--add-data=./application/gui/resources/agent-white.png{sep}u./application/gui/resources", # noqa: E501 - f"--add-data=./application/gui/resources/agent-green.png{sep}./application/gui/resources", # noqa: E501 - f"--add-data=./application/games/*.py{sep}./application/games", - f"--add-data=./application/games/resources/*{sep}./application/games/resources", # noqa: E501 - f"--add-data=./application/alembic/alembic.ini{sep}./application/alembic", - f"--add-data=./application/alembic/env.py{sep}./application/alembic", - f"--add-data=./application/alembic/script.py.mako{sep}./application/alembic", # noqa: E501 - f"--add-data=./application/alembic/versions/*.py{sep}./application/alembic/versions", # noqa: E501 - "--hidden-import=xml.etree.ElementTree", - "--hidden-import=telnetlib", - "--clean", - ] - ) + arguments_list = [ + "agent-smith.py", + "--onefile", + "--noconsole", + "--icon=./application/gui/resources/agent-black.ico", + f"--add-data=./application/config/nginx/*{sep}./application/config/nginx", # noqa: E501 + f"--add-data=./application/gui/resources/agent-white.png{sep}u./application/gui/resources", # noqa: E501 + f"--add-data=./application/gui/resources/agent-green.png{sep}./application/gui/resources", # noqa: E501 + f"--add-data=./application/games/*.py{sep}./application/games", + f"--add-data=./application/games/resources/*{sep}./application/games/resources", # noqa: E501 + f"--add-data=./application/alembic/alembic.ini{sep}./application/alembic", + f"--add-data=./application/alembic/env.py{sep}./application/alembic", + f"--add-data=./application/alembic/script.py.mako{sep}./application/alembic", # noqa: E501 + f"--add-data=./application/alembic/versions/*.py{sep}./application/alembic/versions", # noqa: E501 + "--hidden-import=xml.etree.ElementTree", + "--hidden-import=telnetlib", + "--clean", + ] + + # I + if debug: + arguments_list.remove("--noconsole") + + PyInstaller.__main__.run(arguments_list) if __name__ == "__main__": - main() + args = sys.argv + num_args = len(args) + + if num_args > 1: + if "--debug" in args: + main(debug=True) + else: + main() From b975eff77669c0578ed507235eea64c743d44c80 Mon Sep 17 00:00:00 2001 From: Joshua Reed <11220408+jreed1701@users.noreply.github.com> Date: Sun, 4 Feb 2024 10:10:07 -0700 Subject: [PATCH 7/7] Improve gui resize policies. --- .../gui/widgets/game_arguments_widget.py | 17 ++++++-- .../gui/widgets/game_manager_widget.py | 2 +- application/gui/widgets/new_game_widget.py | 6 +++ docs/developer.md | 5 ++- docs/testing.md | 43 +++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 docs/testing.md diff --git a/application/gui/widgets/game_arguments_widget.py b/application/gui/widgets/game_arguments_widget.py index 26d4779..86ce588 100644 --- a/application/gui/widgets/game_arguments_widget.py +++ b/application/gui/widgets/game_arguments_widget.py @@ -50,7 +50,7 @@ def init_ui(self): QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents ) - self.update_table() + self.update_arguments_table() self.setLayout(self._table_layout) @@ -59,7 +59,17 @@ def init_ui(self): def get_args_dict(self) -> dict: return self._args_dict - def update_table(self, game_arguments=None): + def disable_scroll_bars(self): + self.disable_horizontal_scroll() + self.disable_vertical_scroll() + + def disable_horizontal_scroll(self): + self._table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def disable_vertical_scroll(self): + self._table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def update_arguments_table(self, game_arguments=None): # Allow argument data to be updated by external caller. if game_arguments: self._arg_data = game_arguments @@ -99,7 +109,7 @@ def update_table(self, game_arguments=None): # Value widget for the given row value_widget = None - # TODO - This works... but its janky and can break. + # TODO - This works... but its not built very well and can break. # If someone disables required but not actions then c == 3 will equal the # self._ARG_ACTIONS_COL but its hard coded to 4. Change it to go back and hide the # columns later. @@ -136,6 +146,7 @@ def update_table(self, game_arguments=None): c, QHeaderView.ResizeToContents ) + self._table.horizontalHeader().setStretchLastSection(True) self._table.resizeRowsToContents() self._table.resizeColumnsToContents() self.adjustSize() diff --git a/application/gui/widgets/game_manager_widget.py b/application/gui/widgets/game_manager_widget.py index 1ca2604..8762557 100644 --- a/application/gui/widgets/game_manager_widget.py +++ b/application/gui/widgets/game_manager_widget.py @@ -197,7 +197,7 @@ def _refresh_on_timer(self): ) self._install_games_menu.update_menu_list() - self._current_arg_widget.update_table(game_arguments=game_arguments) + self._current_arg_widget.update_arguments_table(game_arguments=game_arguments) self._timer.setInterval(self.REFRESH_INTERVAL) diff --git a/application/gui/widgets/new_game_widget.py b/application/gui/widgets/new_game_widget.py index a5383ea..9d7a004 100644 --- a/application/gui/widgets/new_game_widget.py +++ b/application/gui/widgets/new_game_widget.py @@ -86,6 +86,7 @@ def _build_inputs(self, game_name): self._arg_widget = GameArgumentsWidget( self._client, args_list, input_frame, disable_cols=disabled_cols ) + self._arg_widget.disable_horizontal_scroll() input_frame_main_layout.addWidget(self._arg_widget) @@ -124,6 +125,7 @@ def _build_inputs(self, game_name): input_frame.setLayout(input_frame_main_layout) input_frame.setFrameStyle(QFrame.StyledPanel | QFrame.Plain) input_frame.setLineWidth(1) + input_frame.adjustSize() return input_frame @@ -156,6 +158,9 @@ def init_ui(self): self.setLayout(self._layout) + self.adjustSize() + self.parentWidget().adjustSize() + self.show() self._initialized = True @@ -171,6 +176,7 @@ def _text_changed(self, game_pretty_name): self._current_inputs = self._build_inputs(game_pretty_name) self._layout.replaceWidget(old_inputs, self._current_inputs) self.adjustSize() + self.parentWidget().adjustSize() def _install_game(self, game_pretty_name): logger.info(f"Installing Game Name: {game_pretty_name}") diff --git a/docs/developer.md b/docs/developer.md index 4b6f03d..c17e18c 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -95,7 +95,10 @@ to set this up properly. This software uses the coverage and pytest python packages for a testing framework. All tests are in the "tests" folder, and are split up by unit tests and functional tests. Any unit test is a simple test of a singular function or independent -object. A functional test is +object. A functional or system test would be a test that covers and end-to-end function; eg installing a game server and +seeing that the game shows up in the quick action menu, for example. + +For more information about testing, [please read here](./testing.md) # Software Development Process diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..06fc00f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,43 @@ +# Testing + +Testing an application is imperative as it grows because as new features and fixes are introduced, +the potential for creating more issues than get solved increases. + +## Test Plan + +The goal is to first, implement unit testing and then system testing with the PyQt GUI. In the end, +all testing (or as much as possible) should become a part of CI/CD checks. + +A word about code coverage - In my experience people associate code coverage with an in-fallable +system. That is, if the code coverage is 95% then the system cannot have any bugs in it, certainly! +The author disagrees with this mentality. Instead, the author belives its best to use code coverage +metrics as a guidline and instead focus on test case coverage. In a perfect world, test driven +development would drive test case coverage, but in reality code gets written and tests are added +both as one can plan for and as issues are found. + +### Desired Unit Testing + +1. Testing API Endpoints. +2. Testing the Token authentication mechanisms. +3. Testing all isolated functions. + +### Desired System Testing + +1. Testing that the GUI can install a game server properly. +2. Testing that the GUI can startup, shutdown and restart an installed game server properly. +3. Testing that the GUI can update a game server properly. +4. Testing that the GUI can uninstall a game server. +5. Testing that installing a new game results in the quick action menu being updated. +6. Testing that uninstalling a game results in the quick action menu being updated. + +### Other testing. + +Other testing might consist of load testing, erroneous input testing, fault testing which is to mean +intentionally causing an error, and much more. + +Ideas: + +1. Try to break API endpoints with usupported HTTP request types and inputs. +2. Run game servers for days on end and then check basic functionality. +3. Try to do odd things like uninstall a game while its running or delete a database record and see + how the system reacts. \ No newline at end of file