diff --git a/src/cli/emuhaven_cli.py b/src/cli/emuhaven_cli.py new file mode 100644 index 0000000..17543ad --- /dev/null +++ b/src/cli/emuhaven_cli.py @@ -0,0 +1,199 @@ +import argparse +from core.config import constants +from core.config.paths import Paths +from core.config.settings import Settings +from core.config.versions import Versions +from core.config.cache import Cache +import os +import webbrowser +from pathlib import Path +from core.emulators.dolphin.runner import Dolphin +from core.emulators.ryujinx.runner import Ryujinx +from core.emulators.xenia.runner import Xenia +from core.emulators.yuzu.runner import Yuzu +from cli.handlers.progress.progress_handler import ProgressHandler + +class EmuHavenCLI: + """ + EmuHaven CLI Plan: + + available options: + + misc: + --open-discord: open the discord server + --open-github: open the github repository + --open-kofi: open the kofi page + --clear-cache: clear the cache + --reset-settings: reset all settings to default + --show-settings: show the current settings + --about: show information about the application + --version: show the version of the application + --help: show the help message + --check-for-updates: check for updates + + config: + --set-setting : set a setting + + core: + --launch-emulator: launch an emulator (flags: --update) + --update: update the emulator before launching + + --delete-emulator : delete an emulator + --install-emulator : install an emulator + --update-emulator : update an emulator (calls install-emulator) + --get-game-titleid-mapping : get the game titleid mapping for an emulator + --download-switch-saves : download switch saves + --install-switch-firmware : install firmware + --install-switch-keys : install keys + + + + """ + def __init__(self, paths: Paths, settings: Settings, versions: Versions, cache: Cache): + self.paths = paths + self.settings = settings + self.versions = versions + self.cache = cache + self.dolphin = Dolphin(settings=self.settings, versions=self.versions) + self.yuzu = Yuzu(settings=self.settings, versions=self.versions) + self.ryujinx = Ryujinx(settings=self.settings, versions=self.versions) + self.xenia = Xenia(settings=self.settings, versions=self.versions) + self.args = self.parse_args() + self.progress_handler = ProgressHandler() + self.run() + + def parse_args(self): + parser = argparse.ArgumentParser(description="EmuHaven CLI") + + # Miscellaneous group + misc_group = parser.add_argument_group('misc', 'Miscellaneous options') + misc_group.add_argument("--open-discord", action="store_true", help="Open the discord server") + misc_group.add_argument("--open-github", action="store_true", help="Open the github repository") + misc_group.add_argument("--open-kofi", action="store_true", help="Open the kofi page") + misc_group.add_argument("--clear-cache", action="store_true", help="Clear the cache directory") + misc_group.add_argument("--version", action="version", help="Show the version of the application", version=constants.App.VERSION.value) + misc_group.add_argument("--check-for-updates", action="store_true", help="Check for updates") + + # Configuration group + config_group = parser.add_argument_group('config', 'Configuration options') + config_group.add_argument("--set-setting", nargs=3, metavar=('setting_type', 'setting_name', 'value'), help="Set a setting") + config_group.add_argument("--reset-settings", action="store_true", help="Reset all settings to default") + config_group.add_argument("--open-settings-file", action="store_true", help="Open the settings file in the default editor") + + # Core group + core_group = parser.add_argument_group('core', 'Core options') + core_group.add_argument("--delete-emulator", metavar='emulator', choices=constants.App.VALID_EMULATOR_NAMES.value, help="Delete an emulator") + core_group.add_argument("--get-switch-game-list", metavar='emulator', help="Get the game list including title ids for an emulator") + core_group.add_argument("--download-switch-saves", metavar='title_id', help="Download switch saves") + core_group.add_argument("--install-switch-firmware", nargs=2, metavar=('version', 'emulator'), help="Install firmware") + core_group.add_argument("--install-switch-keys", metavar='emulator', help="Install keys") + + # Subparsers for install-emulator + subparsers = parser.add_subparsers(dest='command', help="Emulator commands") + + install_emulator_parser = subparsers.add_parser('install-emulator', help='Install an emulator') + install_emulator_parser.add_argument('emulator', choices=constants.App.VALID_EMULATOR_NAMES.value, help='The emulator to install') + install_emulator_parser.add_argument('--custom-archive', help='Path to a custom archive for installation', default=None) + + launch_emulator_parser = subparsers.add_parser('launch-emulator', help='Launch an emulator') + launch_emulator_parser.add_argument('emulator', choices=constants.App.VALID_EMULATOR_NAMES.value, help='The emulator to launch') + launch_emulator_parser.add_argument('--update', action='store_true', help='Update the emulator before launching') + + args = parser.parse_args() + + return args + + def run(self): + if self.args.open_discord: + webbrowser.open(constants.App.DISCORD.value) + + if self.args.open_github: + webbrowser.open(constants.App.GITHUB.value) + + if self.args.open_kofi: + webbrowser.open(constants.App.KOFI.value) + + if self.args.clear_cache: + self.cache.reset() + + if self.args.reset_settings: + self.settings.reset() + + if self.args.open_settings_file: + os.startfile(self.paths.settings_file) + + if self.args.set_setting: + setting_type, setting_name, value = self.args.set_setting + if "path" or "dir" in setting_name: + value = Path(value) + if not value.exists(): + raise ValueError(f"Invalid path: {value}") + match setting_type: + case "dolphin": + if setting_name not in self.settings.dolphin.default_config: + raise ValueError(f"Invalid setting name: {setting_name}") + setattr(self.settings.dolphin, setting_name, value) + case "yuzu": + if setting_name not in self.settings.yuzu.default_config: + raise ValueError(f"Invalid setting name: {setting_name}") + setattr(self.settings.yuzu, setting_name, value) + case "ryujinx": + if setting_name not in self.settings.ryujinx.default_config: + raise ValueError(f"Invalid setting name: {setting_name}") + setattr(self.settings.ryujinx, setting_name, value) + case "xenia": + if setting_name not in self.settings.xenia.default_config: + raise ValueError(f"Invalid setting name: {setting_name}") + setattr(self.settings.xenia, setting_name, value) + case "app": + if setting_name not in self.settings.default_settings: + raise ValueError(f"Invalid setting name: {setting_name}") + setattr(self.settings, setting_name, value) + case _: + raise ValueError(f"Invalid setting type: {setting_type}") + self.settings.save() + + if self.args.delete_emulator: + match self.args.delete_emulator: + case "dolphin": + self.dolphin.delete_dolphin() + case "yuzu": + self.yuzu.delete_yuzu() + case "ryujinx": + self.ryujinx.delete_ryujinx() + case "xenia": + self.xenia.delete_xenia() + case _: + raise ValueError(f"Invalid emulator: {self.args.delete_emulator}") + + if self.args.command == "install-emulator": + emulator = self.args.emulator + custom_archive = self.args.custom_archive + if custom_archive: + custom_archive = Path(custom_archive) + if not custom_archive.exists(): + raise ValueError(f"Invalid path: {custom_archive}") + print(f"Installing {emulator} emulator, custom archive: {custom_archive}") + match emulator: + case "dolphin": + if not custom_archive: + latest_release = self.dolphin.get_dolphin_release() + if not latest_release["status"]: + raise ValueError(f"Failed to get latest Dolphin release: {latest_release['message']}") + latest_release = latest_release["release"] + + download_result = self.dolphin.download_release(latest_release, progress_handler=self.progress_handler) + if not download_result["status"]: + raise ValueError(f"Failed to download Dolphin release: {download_result['message']}") + + extract_result = self.dolphin.extract_release(download_result["download_path"], progress_handler=self.progress_handler) + if not extract_result["status"]: + raise ValueError(f"Failed to extract Dolphin release: {extract_result['message']}") + case "yuzu": + pass + case "ryujinx": + self.ryujinx.install_ryujinx() + case "xenia": + self.xenia.install_xenia() + case _: + raise ValueError(f"Invalid emulator: {self.args.install_emulator}") \ No newline at end of file diff --git a/src/cli/handlers/progress/progress_handler.py b/src/cli/handlers/progress/progress_handler.py new file mode 100644 index 0000000..e4ec306 --- /dev/null +++ b/src/cli/handlers/progress/progress_handler.py @@ -0,0 +1,118 @@ +""" +This is the ProgressHandler class for the GUI + +For the GUI, our progress handler will be a progress bar that updates as the progress is reported. +The progress handler will have a report_progress method that will have to take the completed_data as an argument. +The report_progress method will then update the progress bar with the new values + +We need to create the progress bar class. +""" +from time import perf_counter + +from core.logging.logger import Logger + + + +class ProgressHandler: + def __init__(self): + self.logger = Logger(__name__).get_logger() + self.smoothing_factor = 0.3 + self._operation_start_time = 0 + self._units = "" + self._total_units = 0 + self._last_speed = 0 + self._average_speed = 0 + self._current_units = 0 + self._should_cancel = False + + def start_operation(self, title, total_units, units, status="Starting..."): + + self._average_speed = 0 + self._total_units = total_units + self._should_cancel = False + self._units = units + self._current_units = 0 + self._last_speed = 0 + self._operation_start_time = perf_counter() + + + + def is_total_units_set(self): + return self._total_units > 0 + + def set_total_units(self, total_units): + self._total_units = total_units + + def report_progress(self, completed_units): + + self._current_units = completed_units + # update progress bar, percentage and progress label + + + # calculate download speed + last_speed = completed_units / ( + (perf_counter() - self._operation_start_time) + ) + # use exponential moving average to calculate download speed + self._average_speed = ( + self.smoothing_factor * last_speed + + + (1 - self.smoothing_factor) * self._average_speed + ) + + # calculate time left + if self._average_speed != 0: + time_left = (self._total_units - completed_units) / self._average_speed + minutes, seconds = divmod(int(time_left), 60) + hours, minutes = divmod(minutes, 60) + time_left_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" + else: + time_left_str = "00:00:00" + + self.print_progress_bar(completed_units, self._total_units, prefix='Progress:', suffix='Complete', length=50) + + def print_progress_bar(self, iteration, total, prefix='', suffix='', decimals=1, length=50, fill='█'): + """ + Call in a loop to create terminal progress bar + @params: + iteration - Required : current iteration (Int) + total - Required : total iterations (Int) + prefix - Optional : prefix string (Str) + suffix - Optional : suffix string (Str) + decimals - Optional : positive number of decimals in percent complete (Int) + length - Optional : character length of bar (Int) + fill - Optional : bar fill character (Str) + """ + percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) + filled_length = int(length * iteration // total) + bar = fill * filled_length + '-' * (length - filled_length) + print(f'\r{prefix} |{bar}| {percent}% {suffix}', end='\r') + # Print New Line on Complete + if iteration == total: + print() + + def report_success(self): + pass + + def report_error(self, error): + pass + + def report_configure(self, widget, **kwargs): + pass + + + def should_cancel(self): + return self._should_cancel + + def send_cancel_signal_to_operation(self): + """ + called by external events like cancel buttons to signal the operation to cancel + """ + self._should_cancel = True + self.set_cancel_button_state("disabled") + + def cancel(self): + """ + called by the operation + """ + pass diff --git a/src/core/config/cache.py b/src/core/config/cache.py index 6fcd711..c91152a 100644 --- a/src/core/config/cache.py +++ b/src/core/config/cache.py @@ -32,6 +32,14 @@ def __init__(self, paths: Paths): if not self._is_index_file_valid(): self._create_index_file() + def reset(self): + """ + Reset the cache by removing all files and the index file. + """ + self.logger.info("Resetting the cache") + for file in self.cache_directory.iterdir(): + file.unlink() + def _is_index_file_valid(self): """ Check if the index file is valid. diff --git a/src/core/config/constants.py b/src/core/config/constants.py index 9ba6a4d..2ea2b86 100644 --- a/src/core/config/constants.py +++ b/src/core/config/constants.py @@ -16,6 +16,7 @@ class App(Enum): DEFAULT_COLOUR_THEMES = ["blue", "dark-blue", "green"] VALID_APPEARANCE_MODES = ["dark", "light"] RESULTS_PER_GAME_PAGE = 20 + VALID_EMULATOR_NAMES = ["dolphin", "xenia", "yuzu", "ryujinx"] class GitHubOAuth(Enum): diff --git a/src/core/config/settings.py b/src/core/config/settings.py index c5039d6..c67781d 100644 --- a/src/core/config/settings.py +++ b/src/core/config/settings.py @@ -41,6 +41,15 @@ def __init__(self, paths: Paths): self.create_settings_file() self.load() + def reset(self): + self.logger.info("Resetting settings") + self._settings = self.default_settings.copy() + self.dolphin.reset() + self.ryujinx.reset() + self.yuzu.reset() + self.xenia.reset() + self.save() + def settings_file_valid(self): if not self.settings_file.exists(): self.logger.info("Settings file does not exist") diff --git a/src/core/emulators/dolphin/settings.py b/src/core/emulators/dolphin/settings.py index 490f721..6d86cb0 100644 --- a/src/core/emulators/dolphin/settings.py +++ b/src/core/emulators/dolphin/settings.py @@ -7,13 +7,18 @@ class DolphinSettings: def __init__(self): self.logger = Logger(__name__).get_logger() - self.config = { + self.default_config = { "release_channel": "release", "portable_mode": False, "install_directory": self.get_default_install_directory(), "game_directory": Path().resolve(), "sync_user_data": True, } + self._config = self.default_config.copy() + + def reset(self): + self.logger.info("Resetting Dolphin settings") + self._config = self.default_config.copy() def get_default_install_directory(self): @@ -26,10 +31,10 @@ def get_default_install_directory(self): def _set_property(self, property_name, value): self.logger.debug(f"Setting {property_name} to {value}") - self.config[property_name] = value + self._config[property_name] = value def _get_property(self, property_name): - return self.config.get(property_name) + return self._config.get(property_name) portable_mode = property( fget=lambda self: self._get_property("portable_mode"), diff --git a/src/core/emulators/ryujinx/settings.py b/src/core/emulators/ryujinx/settings.py index b13f4c8..16f459f 100644 --- a/src/core/emulators/ryujinx/settings.py +++ b/src/core/emulators/ryujinx/settings.py @@ -7,13 +7,18 @@ class RyujinxSettings: def __init__(self): self.logger = Logger(__name__).get_logger() - self.config = { + self.default_config = { "install_directory": self.get_default_install_directory(), "portable_mode": False, "release_channel": "master", "last_used_data_path": None, "sync_user_data": True, } + self._config = self.default_config.copy() + + def reset(self): + self.logger.info("Resetting Ryujinx settings") + self._config = self.default_config.copy() def get_default_install_directory(self): @@ -25,10 +30,10 @@ def get_default_install_directory(self): def _set_property(self, property_name, value): self.logger.debug(f"Setting {property_name} to {value}") - self.config[property_name] = value + self._config[property_name] = value def _get_property(self, property_name): - return self.config.get(property_name) + return self._config.get(property_name) install_directory = property( fget=lambda self: self._get_property("install_directory"), diff --git a/src/core/emulators/xenia/runner.py b/src/core/emulators/xenia/runner.py index 44665ab..1379f78 100644 --- a/src/core/emulators/xenia/runner.py +++ b/src/core/emulators/xenia/runner.py @@ -14,11 +14,10 @@ class Xenia: - def __init__(self, gui, settings, versions): + def __init__(self, settings, versions): self.logger = Logger(__name__).get_logger() self.settings = settings self.versions = versions - self.gui = gui self.running = False self.main_progress_frame = None self.data_progress_frame = None diff --git a/src/core/emulators/xenia/settings.py b/src/core/emulators/xenia/settings.py index a0ae93c..ff1f98c 100644 --- a/src/core/emulators/xenia/settings.py +++ b/src/core/emulators/xenia/settings.py @@ -7,12 +7,17 @@ class XeniaSettings: def __init__(self): self.logger = Logger(__name__).get_logger() - self.config = { + self.default_config = { "install_directory": self.get_default_install_directory(), "portable_mode": False, "release_channel": "master", "game_directory": Path().resolve(), } + self._config = self.default_config.copy() + + def reset(self): + self.logger.info("Resetting Xenia settings") + self._config = self.default_config.copy() def get_default_install_directory(self): system = platform.system().lower() @@ -24,10 +29,10 @@ def get_default_install_directory(self): def _set_property(self, property_name, value): self.logger.debug(f"Setting {property_name} to {value}") - self.config[property_name] = value + self._config[property_name] = value def _get_property(self, property_name): - return self.config.get(property_name) + return self._config.get(property_name) install_directory = property( fget=lambda self: self._get_property("install_directory"), diff --git a/src/core/emulators/yuzu/settings.py b/src/core/emulators/yuzu/settings.py index 5129d65..c046db5 100644 --- a/src/core/emulators/yuzu/settings.py +++ b/src/core/emulators/yuzu/settings.py @@ -6,13 +6,19 @@ class YuzuSettings: def __init__(self): self.logger = Logger(__name__).get_logger() - self.config = { + self.default_config = { "install_directory": self.get_default_install_directory(), "portable_mode": False, "release_channel": "mainline", "last_used_data_path": None, "sync_user_data": True, } + self._config = self.default_config.copy() + + def reset(self): + self.logger.info("Resetting Yuzu settings") + self._config = self.default_config.copy() + def get_default_install_directory(self): system = platform.system().lower() @@ -24,10 +30,10 @@ def get_default_install_directory(self): def _set_property(self, property_name, value): self.logger.debug(f"Setting {property_name} to {value}") - self.config[property_name] = value + self._config[property_name] = value def _get_property(self, property_name): - return self.config.get(property_name) + return self._config.get(property_name) install_directory = property( fget=lambda self: self._get_property("install_directory"), diff --git a/src/core/logging/logger.py b/src/core/logging/logger.py index b71a3d5..a1d5cf1 100644 --- a/src/core/logging/logger.py +++ b/src/core/logging/logger.py @@ -4,7 +4,7 @@ class Logger: - def __init__(self, name, console=True): + def __init__(self, name): # Configure the logger self.logger = logging.getLogger(name) self.logger.setLevel(logging.DEBUG) @@ -19,12 +19,11 @@ def __init__(self, name, console=True): datefmt='%Y-%m-%d %H:%M:%S' ) - if console: - # Create a console handler and set the log level - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) - console_handler.setFormatter(formatter) - self.logger.addHandler(console_handler) + # Create a console handler and set the log level + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + console_handler.setFormatter(formatter) + self.logger.addHandler(console_handler) # create a file handler and set the log level try: file_handler = logging.FileHandler(f"{App.NAME.value}.log") @@ -32,6 +31,7 @@ def __init__(self, name, console=True): file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) except PermissionError: + # in case of permission error, log to console self.logger.error("Permission denied to write to log file") def get_logger(self): diff --git a/src/gui/frames/my_switch_games_frame.py b/src/gui/frames/my_switch_games_frame.py index ded4e31..d425573 100644 --- a/src/gui/frames/my_switch_games_frame.py +++ b/src/gui/frames/my_switch_games_frame.py @@ -131,9 +131,7 @@ def download_icon(self, title_id, icon_url, button): return {} icon_path = download_result["download_path"] - add_to_cache_result = self.cache.add_file(f"{title_id}_icon", icon_path) - if not add_to_cache_result["status"]: - return {} + self.cache.add_file(f"{title_id}_icon", icon_path) cache_query_result = self.cache.get_file(f"{title_id}_icon") if not cache_query_result: @@ -210,7 +208,7 @@ def download_titledb(self): titledb = cache_lookup_result["data"] - self.load_titledb(titledb, refetch_on_error=False) + self.load_titledb(titledb) self.fetching_titledb = False return { diff --git a/src/gui/frames/xenia/xenia_frame.py b/src/gui/frames/xenia/xenia_frame.py index fb45477..2684129 100644 --- a/src/gui/frames/xenia/xenia_frame.py +++ b/src/gui/frames/xenia/xenia_frame.py @@ -15,7 +15,7 @@ class XeniaFrame(EmulatorFrame): def __init__(self, master, paths, settings, versions, assets, cache, event_manager: ThreadEventManager): super().__init__(parent_frame=master, paths=paths, settings=settings, versions=versions, assets=assets) - self.xenia = Xenia(self, settings, versions) + self.xenia = Xenia(settings, versions) self.paths = paths self.cache = cache self.event_manager = event_manager diff --git a/src/main.py b/src/main.py index 8efb3dd..a5892c7 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ import sys import customtkinter - +from cli.emuhaven_cli import EmuHavenCLI from core.config.assets import Assets from core.config.cache import Cache from core.config.paths import Paths @@ -23,7 +23,7 @@ args = sys.argv[1:] if args: logger.info("Starting the application in CLI mode") - + app = EmuHavenCLI(paths=paths, settings=settings, versions=versions, cache=cache) else: try: # attempt to load assets