Files
Launcher/core_lib/game/profile.py
T

483 lines
14 KiB
Python

"""
This profile module also compatible with official launcher profile format
"""
import datetime
import json
import logging
import os
import uuid
from copy import deepcopy
from pathlib import Path
from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \
ProfileLoadException
logger = logging.getLogger("Launcher.CoreLib")
def get_profile_sample() -> dict:
return {
"lastPlayedProfileID": None,
"profiles": {},
"settings": {
# Not all settings are included
"profileSorting": "ByLastPlayed",
"showGameLog": True,
},
"version": 6
}
class Profile:
def __init__(self, version_id: str, created: datetime.datetime,
last_used: datetime.datetime | None, name: str="", icon: str | Path="",
game_dir: str | Path="", java_args: str="", resolution_raw: dict | None = None, type_raw="custom"):
self.version_id = version_id
self.created = created
self.last_used = last_used
self.name = name
self.icon: str | Path = icon
self.game_dir: Path | str = game_dir
self.java_args: str = java_args
self.resolution: dict | None = resolution_raw
self.type: str = type_raw
def _get_current_datetime_string() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
def _create_profile_data(version_id: str, version_type: str, java_args: str= "", game_dir: str= "", icon: str= "", name: str= "", resolution: dict=None) -> dict:
content = {
"name": name,
"created": _get_current_datetime_string(),
"gameDir": game_dir,
"lastVersionId": version_id,
"lastUsed": "",
"type": version_type,
"javaArgs": java_args,
"icon": icon,
}
if isinstance(resolution, dict):
content["resolution"] = resolution
return content
def create_profile_data_from_object(p: Profile):
created: datetime.datetime | str = p.created
last_used: datetime.datetime | str | None = p.last_used
if isinstance(created, datetime.datetime):
created = created.isoformat().replace("+00:00", "Z")
elif not isinstance(created, str):
raise TypeError("\"created\" key must be a datetime.datetime or str")
game_dir: str | Path = p.game_dir
if isinstance(game_dir, Path):
game_dir = game_dir.as_posix()
icon: str | Path = p.icon
if isinstance(icon, Path):
icon = icon.as_posix()
content = {
"name": p.name,
"created": created,
"gameDir": game_dir,
"lastVersionId": p.version_id,
"type": p.type,
"javaArgs": p.java_args,
"icon": icon,
}
if isinstance(last_used, datetime.datetime):
last_used = last_used.isoformat().replace("+00:00", "Z")
elif isinstance(last_used, str):
pass
elif last_used is not None:
raise TypeError("\"last_used\" key must be a datetime.datetime or str")
if last_used:
content["lastUsed"] = last_used
if isinstance(p.resolution, dict):
content["resolution"] = p.resolution
return content
def create_profile(version_id: str, version_type: str, java_args: str="", game_dir: str="",
icon: str="", name: str="", resolution: dict=None, profiles: dict | None=None,
max_generate_retry=10) -> tuple[dict, str]:
"""
Create a new profile with the given arguments
:param version_id:
:param version_type:
:param java_args:
:param game_dir:
:param icon:
:param name:
:param resolution:
:param profiles: If not given, this function will create a new profiles dict instead
:param max_generate_retry:
:return:
profile: dict
new_profile_id: str
"""
if profiles is None:
profiles = get_profile_sample()
profile = _create_profile_data(version_id, version_type, java_args, game_dir, icon, name, resolution)
for _ in range(max_generate_retry):
profile_id = str(uuid.uuid4().hex)
if profile_id not in profiles["profiles"]:
break
else:
raise ProfileException(
"Unable to generate a unique profile ID."
)
profiles["profiles"][profile_id] = profile
return profile, profile_id
def create_or_save_profile_file(profiles: dict, profile_filepath: Path, profile_file_bak_filepath: Path | None=None,
overwrite=True, create_backup=True) -> None:
"""
Create or save a new profile
:param profiles:
:param profile_filepath:
:param profile_file_bak_filepath:
:param overwrite:
:param create_backup:
:return:
"""
profile_filepath.parent.mkdir(parents=True, exist_ok=True)
if profile_filepath.exists() and not overwrite:
raise ProfileSaveException(
f"Profile file already exists: {profile_filepath}"
)
# Create backup path
if create_backup and profile_file_bak_filepath is None:
profile_file_bak_filepath = profile_filepath.with_suffix(
profile_filepath.suffix + ".bak"
)
temporary_path = profile_filepath.with_suffix(
profile_filepath.suffix + ".tmp"
)
try:
# Write temp profile file first
temporary_path.write_text(
json.dumps(profiles, indent=4, ensure_ascii=False),
encoding="utf-8",
)
except Exception as e:
raise ProfileSaveException(
"Unable to create profile file: {}".format(e)
)
try:
if create_backup and profile_filepath.exists():
if profile_file_bak_filepath.exists():
profile_file_bak_filepath.unlink()
# Rename old profile with an ".bak" extension
os.replace(
profile_filepath,
profile_file_bak_filepath,
)
# Rename new profile file
os.replace(temporary_path, profile_filepath)
except OSError as exc:
if (create_backup and profile_file_bak_filepath is not None and profile_file_bak_filepath.exists()
and not profile_filepath.exists()):
os.replace(
profile_file_bak_filepath,
profile_filepath,
)
raise ProfileSaveException(
f"Unable to save profile file: {exc}"
) from exc
except Exception as e:
raise ProfileSaveException(
"Unable to replace path from old to new profile: {}".format(e)
)
def read_profile_file(profile_filepath: Path) -> dict:
"""
Read profile from existing file
:param profile_filepath:
:return:
profiles: dict
"""
if not profile_filepath.exists():
raise ProfileException(
"Profile file does not exist: {}".format(profile_filepath)
)
try:
with open(str(profile_filepath), "r", encoding="utf-8") as f:
return json.loads(f.read())
except Exception as e:
raise ProfileLoadException(
"Unable to read profile file: {}".format(e)
)
def _check_profile(profile: dict, fix_wrong=False) -> bool:
if not isinstance(profile, dict):
return False
profile_name = profile.get("name")
last_version_id = profile.get("lastVersionId", None)
if not last_version_id or not isinstance(last_version_id, str):
return False
elif profile_name is None or not isinstance(profile_name, str):
return False
resolution = profile.get("resolution", None)
if resolution is None:
return True
if not isinstance(resolution, dict):
if fix_wrong:
profile.pop("resolution", None)
return True
return False
width = resolution.get("width")
height = resolution.get("height")
if not isinstance(width, int) or isinstance(width, bool) or width <= 0:
return False
if not isinstance(height, int) or isinstance(height, bool) or height <= 0:
return False
return True
def is_profiles_valid(profiles: dict, fix_wrong=False, ignore_invalid_profile=False) -> bool:
"""
Check if a profiles dictionary is valid
:param profiles:
:param fix_wrong:
:param ignore_invalid_profile:
:return:
filtered_profiles: dict
"""
if not isinstance(profiles, dict):
return False
profiles_map = profiles.get("profiles", None)
settings = profiles.get("settings", None)
if not isinstance(profiles_map, dict):
return False
if not isinstance(settings, dict):
return False
invalid_ids = []
for profile_id, profile in profiles_map.items():
if not _check_profile(profile, fix_wrong=fix_wrong):
invalid_ids.append(profile_id)
if ignore_invalid_profile:
for profile_id in invalid_ids:
del profiles_map[profile_id]
return True
return not invalid_ids
def is_profile_valid(profile_data: dict, fix_wrong=False):
return _check_profile(profile_data, fix_wrong=fix_wrong)
def is_profile_exists(profiles: dict, profile_id: str) -> bool:
if not isinstance(profiles, dict):
logger.warning("Profile type is not valid: {}".format(type(profiles)))
return False
profiles_map: dict = profiles.get("profiles")
if not isinstance(profiles_map, dict):
logger.warning("Profile map type is not valid: {}".format(type(profiles_map)))
return False
return profile_id in profiles_map
def list_profiles(profiles: dict) -> dict:
"""
List all profiles that inside profiles data
:param profiles:
:return:
"""
return profiles.get("profiles", {})
def get_profile(profiles: dict, profile_id: str) -> dict:
profile_map = list_profiles(profiles)
try:
return profile_map[profile_id]
except KeyError as exc:
raise ProfileKeyNotFoundException(
f"Specified profile id {profile_id} not found."
) from exc
def set_current_profile_id(profiles: dict, profile_id: str):
"""
Set current profile id
INFO: You should ensure profile id is existing in profiles before calling this function.
:param profiles:
:param profile_id:
:return:
"""
profiles["lastPlayedProfileID"] = profile_id
def get_current_profile_id(profiles: dict) -> str | None:
return profiles.get("lastPlayedProfileID", None)
get_last_played_profile_id = get_current_profile_id
def add_profile(profiles: dict, profile: dict, max_generate_retry=10) -> tuple[dict, str]:
"""
Add a new profile
:param profiles:
:param profile:
:param max_generate_retry:
:return:
profiles: dict
new_profile_id: str
"""
for _ in range(max_generate_retry):
profile_id = str(uuid.uuid4().hex)
if profile_id not in profiles["profiles"]:
break
else:
raise ProfileException(
"Unable to generate a unique profile ID."
)
profiles["profiles"][profile_id] = profile
return profiles, profile_id
def remove_profile(profiles: dict, profile_id: str):
"""
Remove a profile
:param profiles:
:param profile_id:
:return:
"""
profiles_map = list_profiles(profiles)
if not profiles_map:
raise ProfileException(
"No profiles exists"
)
try:
del profiles_map[profile_id]
except KeyError:
raise ProfileKeyNotFoundException("Specified profile id {} not found.".format(profile_id))
def duplicate_profile(profiles: dict, profile_id: str, new_name: str | None=None, max_generate_retry: int=10) -> str:
"""
Duplicate a profile
:param profiles:
:param profile_id:
:param new_name:
:param max_generate_retry:
:return:
new_profile_id: str
"""
profiles_map = list_profiles(profiles)
if not profiles_map:
raise ProfileException(
"No profiles exists"
)
for _ in range(max_generate_retry):
new_profile_id = str(uuid.uuid4().hex)
if new_profile_id not in profiles_map:
break
else:
raise ProfileException(
"Unable to generate a unique profile ID."
)
for p_id in profiles_map:
if p_id == profile_id:
new_profile = deepcopy(profiles_map[profile_id])
if new_name is not None:
new_profile["name"] = new_name
else:
new_profile["name"] = f'{new_profile.get("name", "")} Copy'
profiles["profiles"][new_profile_id] = new_profile
return new_profile_id
raise ProfileKeyNotFoundException(
"Specified profile id {} not found.".format(profile_id)
)
def convert_profile_data_to_object(profiles: dict, profile_id: str) -> Profile:
profile = get_profile(profiles, profile_id)
created: str | datetime.datetime = profile.get("created", datetime.datetime.now())
last_used: None | str | datetime.datetime = profile.get("lastUsed", None)
if not isinstance(created, datetime.datetime):
try:
created = datetime.datetime.fromisoformat(created)
except ValueError:
created = datetime.datetime.now()
if isinstance(last_used, str):
try:
last_used = datetime.datetime.fromisoformat(last_used)
except ValueError:
last_used = None
item = Profile(
version_id=profile.get("lastVersionId", ""),
created=created,
last_used=last_used,
name=profile.get("name", ""),
icon=profile.get("icon", ""),
game_dir=profile.get("gameDir", ""),
java_args=profile.get("javaArgs", ""),
resolution_raw=profile.get("resolution", {}),
type_raw=profile.get("type", "custom"),
)
return item
def update_profile(profiles: dict, profile_id: str, profile_data: dict, fix_wrong: bool=False) -> tuple[dict, str]:
if not is_profile_exists(profiles, profile_id):
raise ProfileException(
"Specified profile id {} not found.".format(profile_id)
)
if not is_profile_valid(profile_data, fix_wrong=fix_wrong):
raise ProfileException(
"Provided profile data is not valid."
)
profiles["profiles"][profile_id] = profile_data
return profiles, profile_id