""" IMPORTANT: Still under construction! """ import copy from dataclasses import dataclass from pathlib import Path from PySide6.QtCore import QObject, Signal from datetime import datetime from core_lib.common.exception import ProfileException, ProfileLoadException, ProfileSaveException from core_lib.game.profile import read_profile_file, is_profiles_valid, \ get_profile_sample, create_or_save_profile_file, list_profiles as list_profiles_raw, convert_profile_data_to_object, \ is_profile_exists, create_profile as create_profile_raw, update_profile as update_profile_raw, is_profile_valid, \ get_current_profile_id, set_current_profile_id, remove_profile, duplicate_profile from manager.widgets import AskForRequest DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json") @dataclass(frozen=True) class ProfileSummary: id: str name: str version_id: str version_type: str icon: str last_used: datetime | None @dataclass(frozen=True) class ProfileDetail(ProfileSummary): game_dir: Path | None java_args: str resolution: tuple[int, int] | None created: datetime @dataclass(frozen=True) class CreateProfileRequest: name: str version_id: str version_type: str = "custom" game_dir: Path | None = None java_args: str = "" icon: str = "" resolution: tuple[int, int] | None = None class _Profile_UnsetType: pass _UNSET = _Profile_UnsetType() @dataclass(frozen=True) class UpdateProfileRequest: name: str | None = None version_id: str | None = None version_type: str | None = None game_dir: Path | None | _Profile_UnsetType = _UNSET java_args: str | None | _Profile_UnsetType = _UNSET icon: str | None = None resolution: tuple[int, int] | None | _Profile_UnsetType = _UNSET @dataclass(frozen=True) class LaunchProfile: profile_id: str version_id: str version_type: str game_dir: Path java_args: str resolution: tuple[int, int] | None @dataclass(frozen=True) class ValidationResult: errors: dict[str, str] @property def is_valid(self) -> bool: return not self.errors class ProfileManager(QObject): profile_load = Signal() profiles_changed = Signal() profile_created = Signal(str) profile_updated = Signal(str) profile_removed = Signal(str) current_profile_changed = Signal(object) # str | None error_occurred = Signal(str, str) # error_code, message replace_relative_path = Signal(str) def __init__(self, app, widget_manager): QObject.__init__(self, app) self.app = app self.widget_manager = widget_manager self.profiles = {} self._path: Path | None = None self._is_modified = False self.profile_load.connect( self.load, ) @property def default_profile_path(self): return Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH) # File lifetime def create_profiles(self, path: str | Path | None=None) -> bool: path = Path(path) if path is not None else self.default_profile_path try: new_profiles = get_profile_sample() create_or_save_profile_file(new_profiles, path, create_backup=False) return True except ProfileSaveException as e: self.widget_manager.signal.show_error_message.emit( "Unable to create profile file: {}".format(e.user_message), ) return False except Exception as e: self.widget_manager.signal.show_error_message.emit( "Unable to create profile file. An unexpected error occurred: {}".format(e), ) return False def load(self, path: str | Path | None=None, return_value: bool=False) -> tuple[bool, None | dict]: """ Load profiles :param path: If provided, this path becomes the current profile path after loading succeeds. :param return_value: :return: """ path = Path(path) if path is not None else self.default_profile_path try: # Prevent existing data from being overwritten if self._is_modified: request = AskForRequest("ProfileManager", "Would you like to save the existing profile" " before reload? (If not existing data will be overwritten)") self.widget_manager.signal.askyesno.emit(request) if not request.wait(60): self.widget_manager.signal.show_error_message.emit( "Timed out while waiting for confirmation. Please try again." ) return False, None result = request.result() if result: save_result = self.save() if not save_result: self.widget_manager.signal.show_error_message.emit( "Unable to load profile due to saving error." ) return False, None # Create new profile if the target profile file does not exist if not path.exists() and not self.create_profiles(path): return False, None result = read_profile_file(path) # Profile validation if not is_profiles_valid(result): self.widget_manager.signal.show_error_message.emit( "Invalid profile file. Try restarting the profile in settings page.", ) return False, None self.profiles = result self._path = path self._is_modified = False self.profiles_changed.emit() if return_value: return True, copy.deepcopy(result) return True, None except (ProfileException, ProfileLoadException) as e: self.widget_manager.signal.show_error_message.emit( "Unable to load profile file: {}".format(e.user_message), ) return False, None except Exception as e: self.widget_manager.signal.show_error_message.emit( "Unable to load profile file. An unexpected error occurred: {}".format(e), ) return False, None def save(self, path: str | Path | None=None, create_backup: bool=True) -> bool: path = Path(path) if path is not None else self.path try: if not is_profiles_valid(self.profiles): self.widget_manager.signal.show_error_message.emit( "Current profile store in memory is not a valid profile.", ) return False create_or_save_profile_file( self.profiles, path, create_backup=create_backup, ) self._path = path self._is_modified = False return True except (ProfileException, ProfileSaveException) as e: self.widget_manager.signal.show_error_message.emit( "Unable to save profile file: {}".format(e.user_message), ) return False except Exception as e: self.widget_manager.signal.show_error_message.emit( "Unable to save profile file. An unexpected error occurred: {}".format(e), ) return False def reload(self) -> tuple[bool, dict | None]: return self.load(path=self.path) # Profile search def list_profiles(self) -> list[ProfileSummary]: result = [] profiles = list_profiles_raw(self.profiles) for profile_id in profiles: profile = convert_profile_data_to_object(self.profiles, profile_id) profile_summary = ProfileSummary( icon=str(profile.icon), id=profile_id, last_used=profile.last_used, name=profile.name, version_id=profile.version_id, version_type=profile.type ) result.append(profile_summary) return result def get_profile(self, profile_id: str) -> ProfileDetail | None: if not self.contains(profile_id, show_error=True): return None profile = convert_profile_data_to_object( self.profiles, profile_id, ) resolution = None if profile.resolution: # Put resolution data to profile detail resolution = ( profile.resolution["width"], profile.resolution["height"], ) return ProfileDetail( id=profile_id, name=profile.name, version_id=profile.version_id, version_type=profile.type, icon=str(profile.icon), last_used=profile.last_used, game_dir=Path(profile.game_dir) if profile.game_dir else None, java_args=profile.java_args, resolution=resolution, created=profile.created, ) def contains(self, profile_id: str, show_error: bool=False) -> bool: result = is_profile_exists(self.profiles, profile_id) if not result and show_error: self.widget_manager.signal.show_error_message.emit( "Profile ID {} does not exist.".format(profile_id), ) return result # CRUD def create_profile(self, request: CreateProfileRequest) -> str | None: if not self.is_loaded: result, _ = self.load() if not result: return None validation: ValidationResult = self.validate(request) if not validation.is_valid: message = "\n".join(validation.errors.values()) self.widget_manager.signal.show_error_message.emit(message) return None # Check game dir if request.game_dir is not None: game_dir = Path(request.game_dir).as_posix() else: default_game_dir = self.app.default_game_dir game_dir = default_game_dir.as_posix() try: # Apply resolution resolution = None if request.resolution is not None: width, height = request.resolution resolution = { "width": width, "height": height, } # Copy profiles before update updated_profiles = copy.deepcopy(self.profiles) _, profile_id = create_profile_raw( version_id=request.version_id, version_type=request.version_type, java_args=request.java_args, game_dir=game_dir, icon=request.icon, name=request.name, resolution=resolution, profiles=updated_profiles, ) if not is_profiles_valid(updated_profiles): raise ProfileException("Created profile data is invalid.") self.profiles = updated_profiles # Update change event and flag self._is_modified = True self.profile_created.emit(profile_id) self.profiles_changed.emit() return profile_id except ProfileException as e: self.widget_manager.signal.show_error_message.emit( f"Unable to create profile: {e.user_message}" ) return None except (TypeError, ValueError) as e: self.widget_manager.signal.show_error_message.emit( f"Invalid profile request: {e}" ) return None def update_profile(self, profile_id: str, changes: UpdateProfileRequest) -> bool: # Check profile exists if not self.contains(profile_id, show_error=True): return False try: # Copy before the actual update. updated_profiles = copy.deepcopy(self.profiles) profile_data = copy.deepcopy( list_profiles_raw(updated_profiles)[profile_id] ) # Check profile value change if changes.name is not None: profile_data["name"] = changes.name if changes.version_id is not None: profile_data["lastVersionId"] = changes.version_id if changes.version_type is not None: profile_data["type"] = changes.version_type if changes.game_dir is not _UNSET: if changes.game_dir is None: profile_data["gameDir"] = "" else: profile_data["gameDir"] = Path( changes.game_dir ).as_posix() if changes.java_args is not _UNSET: profile_data["javaArgs"] = ( "" if changes.java_args is None else changes.java_args ) if changes.icon is not None: profile_data["icon"] = changes.icon if changes.resolution is not _UNSET: if changes.resolution is None: profile_data.pop("resolution", None) else: width, height = changes.resolution profile_data["resolution"] = { "width": width, "height": height, } update_profile_raw( updated_profiles, profile_id, profile_data, ) if profile_data == list_profiles_raw(self.profiles)[profile_id]: return True # Update event and flag self.profiles = updated_profiles self._is_modified = True self.profile_updated.emit(profile_id) self.profiles_changed.emit() return True except (ProfileException, TypeError, ValueError) as e: self.widget_manager.signal.show_error_message.emit( f"Unable to update profile: {e}" ) return False def remove_profile(self, profile_id: str) -> bool: if not self.contains(profile_id, show_error=True): return False try: updated_profiles = copy.deepcopy(self.profiles) is_current_changed = get_current_profile_id(self.profiles) == profile_id # !!!COPY BEFORE REMOVE!!! remove_profile(updated_profiles, profile_id) # Unset current profile id if is same as removed profile's id if is_current_changed: updated_profiles["lastPlayedProfileID"] = None self.profiles = updated_profiles # event self._is_modified = True self.profile_removed.emit(profile_id) if is_current_changed: self.current_profile_changed.emit(None) self.profiles_changed.emit() return True except ProfileException as e: self.widget_manager.signal.show_error_message.emit( f"Unable to remove profile: {e.user_message}" ) return False except Exception as e: self.widget_manager.signal.show_error_message.emit( f"Unable to remove profile. An unknown error occurred: {e}" ) return False def duplicate_profile(self, profile_id: str, new_name: str | None = None) -> str | None: if not self.contains(profile_id, show_error=True): return None try: updated_profiles = copy.deepcopy(self.profiles) new_profile_id = duplicate_profile( updated_profiles, profile_id, new_name, ) # Update event and flag self.profiles = updated_profiles self._is_modified = True self.profile_created.emit(new_profile_id) self.profiles_changed.emit() return new_profile_id except ProfileException as e: self.widget_manager.signal.show_error_message.emit( f"Unable to duplicate profile: {e.user_message}" ) except Exception as e: self.widget_manager.signal.show_error_message.emit( f"Unable to duplicate profile. An unknown error occurred: {e}" ) # Profile switch def set_current_profile(self, profile_id: str | None) -> bool: """ Set the current profile ID. :param profile_id: INFO: Set the current profile ID to None if you want to unset it :return: """ if not self.is_loaded: success, _ = self.load() if not success: return False if profile_id is not None and not self.contains(profile_id, show_error=True): return False try: existing_id = get_current_profile_id(self.profiles) # ignore same id request if profile_id == existing_id: return True if existing_id is not None: self.app.logger.debug( "Replacing current profile ID {} to {}".format(existing_id, profile_id) ) set_current_profile_id(self.profiles, profile_id) # event and flag update self._is_modified = True self.current_profile_changed.emit(profile_id) self.profiles_changed.emit() return True except ProfileException as e: self.widget_manager.signal.show_error_message.emit( f"Unable to set current profile ID: {e.user_message}" ) return False except Exception as e: self.widget_manager.signal.show_error_message.emit( "Unable to set current profile ID. An unknown error occurred {}".format(str(e)) ) return False def get_current_profile_id(self) -> str | None: if not self.is_loaded: success, _ = self.load() if not success: return None return get_current_profile_id(self.profiles) def get_launch_profile(self, profile_id: str) -> LaunchProfile | None: profile = self.get_profile(profile_id) if profile is None: return None # Use default game_dir if is unset game_dir = ( profile.game_dir if profile.game_dir is not None else self.app.default_game_dir ) return LaunchProfile( profile_id=profile.id, version_id=profile.version_id, version_type=profile.version_type, game_dir=game_dir, java_args=profile.java_args, resolution=profile.resolution, ) # Validation @staticmethod def validate(request: CreateProfileRequest) -> ValidationResult: errors = {} if not isinstance(request.name, str) or not request.name.strip(): errors["name"] = "Profile name cannot be empty." if not isinstance(request.version_id, str) or not request.version_id.strip(): errors["version_id"] = "Version ID cannot be empty." if not isinstance(request.version_type, str) or not request.version_type.strip(): errors["version_type"] = "Version type cannot be empty." if not isinstance(request.java_args, str): errors["java_args"] = "Java arguments must be a string." if not isinstance(request.icon, str): errors["icon"] = "Icon must be a string." if request.game_dir is not None: try: Path(request.game_dir) except (TypeError, ValueError): errors["game_dir"] = "Game directory is invalid." if request.resolution is not None: if not isinstance(request.resolution, tuple) or len(request.resolution) != 2: errors["resolution"] = ( "Resolution must contain width and height." ) else: width, height = request.resolution if (not isinstance(width, int) or isinstance(width, bool) or width <= 0 or not isinstance(height, int) or isinstance(height, bool) or height <= 0): errors["resolution"] = ( "Resolution width and height must be positive integers." ) return ValidationResult(errors=errors) @staticmethod def validate_update(request: UpdateProfileRequest) -> ValidationResult: errors = {} if not isinstance(request.name, str) or not request.name.strip(): errors["name"] = "Profile name cannot be empty." if not isinstance(request.version_id, str) or not request.version_id.strip(): errors["version_id"] = "Version ID cannot be empty." if not isinstance(request.version_type, str) or not request.version_type.strip(): errors["version_type"] = "Version type cannot be empty." if not isinstance(request.java_args, str): errors["java_args"] = "Java arguments must be a string." if not isinstance(request.icon, str): errors["icon"] = "Icon must be a string." if request.game_dir is not _UNSET: try: Path(request.game_dir) except (TypeError, ValueError): errors["game_dir"] = "Game directory is invalid." if request.game_dir is not _UNSET: if not isinstance(request.resolution, tuple) or len(request.resolution) != 2: errors["resolution"] = ( "Resolution must contain width and height." ) else: width, height = request.resolution if (not isinstance(width, int) or isinstance(width, bool) or width <= 0 or not isinstance(height, int) or isinstance(height, bool) or height <= 0): errors["resolution"] = ( "Resolution width and height must be positive integers." ) return ValidationResult(errors=errors) # Property @property def path(self) -> Path | None: if self._path is None: self._path = Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH) return self._path @property def is_loaded(self) -> bool: return bool(self.profiles) and is_profiles_valid(self.profiles) @property def is_modified(self) -> bool: return self._is_modified