Files
Launcher/core_lib/game/profile.py
T
2026-07-14 22:22:58 +08:00

431 lines
12 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_profile_file(profiles: dict, profile_filepath: Path, profile_file_bak_filepath: Path | None=None,
overwrite=True, create_backup=True) -> None:
"""
Create 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 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 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, max_generate_retry=10) -> str:
"""
Duplicate a profile
:param profiles:
:param profile_id:
: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:
profiles["profiles"][new_profile_id] = deepcopy(profiles_map[profile_id])
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