Files
Launcher/manager/profile.py
T

704 lines
23 KiB
Python

"""
Profile Manager
A Minecraft profile manager that completely support official Minecraft Launcher.
"""
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, \
get_current_profile_id, set_current_profile_id, remove_profile, duplicate_profile
from core_lib.game.version_info import LATEST_VERSION_ALIASES
from manager.widgets import AskForRequest
DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json")
@dataclass(frozen=True)
class ProfileSummary:
display_name: str
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:
display_name: str
name: str
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,
)
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(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
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(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
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 _get_profile_display_name(self, existing_name: str, version_id: str) -> str:
if existing_name.strip():
return existing_name.strip()
return self._get_profile_alias_display_name(version_id)
@staticmethod
def _get_profile_alias_display_name(version_id: str) -> str:
alias = LATEST_VERSION_ALIASES.get(version_id)
if alias:
return alias.get("displayName", version_id)
return "Unnamed Profile"
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(
display_name=self._get_profile_display_name(
profile.name,
profile.version_id,
),
name=profile.name,
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