540 lines
16 KiB
Python
540 lines
16 KiB
Python
"""
|
|||
|
|
A launcher profile module. Contains many useful function to manage minecraft account
|
||
|
|
INFO: The account data generated by this module is not compatible with official launcher profile!
|
||
|
|
|
||
|
|
This module is vibed too!
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import base64
|
||
|
|
import binascii
|
||
|
|
import datetime
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
from copy import deepcopy
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from core_lib.common.common import bytes_to_data_url
|
||
|
|
from core_lib.common.exception import (
|
||
|
|
AccountException,
|
||
|
|
AccountKeyNotFoundException,
|
||
|
|
AccountLoadException,
|
||
|
|
AccountSaveException,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger("Launcher.CoreLib")
|
||
|
|
|
||
|
|
|
||
|
|
def get_account_sample() -> dict:
|
||
|
|
return {"accounts": {}, "activeAccountId": None}
|
||
|
|
|
||
|
|
|
||
|
|
get_accounts_sample = get_account_sample
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Account:
|
||
|
|
account_id: str
|
||
|
|
username: str
|
||
|
|
access_token: str
|
||
|
|
refresh_token: str
|
||
|
|
access_token_expires_at: str | datetime.datetime
|
||
|
|
refresh_token_expires_at: str | datetime.datetime
|
||
|
|
minecraft_uuid: str = ""
|
||
|
|
minecraft_name: str = ""
|
||
|
|
avatar: str = ""
|
||
|
|
type: str = "msa"
|
||
|
|
user_properties: list = field(default_factory=list)
|
||
|
|
|
||
|
|
|
||
|
|
def _datetime_string(value: str | datetime.datetime, field_name: str) -> str:
|
||
|
|
if isinstance(value, str):
|
||
|
|
return value
|
||
|
|
if isinstance(value, datetime.datetime):
|
||
|
|
return value.isoformat().replace("+00:00", "Z")
|
||
|
|
raise TypeError(f'"{field_name}" must be a datetime.datetime or str')
|
||
|
|
|
||
|
|
|
||
|
|
def create_account_data_from_object(account: Account) -> dict:
|
||
|
|
"""
|
||
|
|
Convert Account object to dictionary
|
||
|
|
:param account:
|
||
|
|
:return:
|
||
|
|
account_dict: dict
|
||
|
|
"""
|
||
|
|
return {
|
||
|
|
"accessToken": account.access_token,
|
||
|
|
"accessTokenExpiresAt": _datetime_string(
|
||
|
|
account.access_token_expires_at, "access_token_expires_at"
|
||
|
|
),
|
||
|
|
"refreshToken": account.refresh_token,
|
||
|
|
"refreshTokenExpiresAt": _datetime_string(
|
||
|
|
account.refresh_token_expires_at, "refresh_token_expires_at"
|
||
|
|
),
|
||
|
|
"avatar": account.avatar,
|
||
|
|
"accountID": account.account_id,
|
||
|
|
"minecraftProfile": {
|
||
|
|
"uuid": account.minecraft_uuid,
|
||
|
|
"name": account.minecraft_name,
|
||
|
|
},
|
||
|
|
"type": account.type,
|
||
|
|
"userProperties": deepcopy(account.user_properties),
|
||
|
|
"username": account.username,
|
||
|
|
}
|
||
|
|
|
||
|
|
SUPPORT_DATA_URL_IMAGE_TYPE = [
|
||
|
|
"image/apng",
|
||
|
|
"image/avif",
|
||
|
|
"image/bmp",
|
||
|
|
"image/gif",
|
||
|
|
"image/jpeg",
|
||
|
|
"image/png",
|
||
|
|
"image/svg+xml",
|
||
|
|
"image/tiff",
|
||
|
|
"image/vnd.microsoft.icon", # .ico
|
||
|
|
"image/webp",
|
||
|
|
]
|
||
|
|
|
||
|
|
def is_valid_account_avatar(avatar: str | bytes, support_type: list[str] | None=None) -> bool:
|
||
|
|
if isinstance(avatar, bytes):
|
||
|
|
try:
|
||
|
|
avatar = bytes_to_data_url(avatar)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not isinstance(avatar, str):
|
||
|
|
return False
|
||
|
|
|
||
|
|
# For empty avatar (not set)
|
||
|
|
if isinstance(avatar, str) and not avatar:
|
||
|
|
return True
|
||
|
|
|
||
|
|
if support_type is None:
|
||
|
|
support_type = SUPPORT_DATA_URL_IMAGE_TYPE
|
||
|
|
|
||
|
|
try:
|
||
|
|
header, encoded_data = avatar.split(",", 1)
|
||
|
|
except ValueError:
|
||
|
|
# For keyword avatar (This need launcher to replace the keyword to mapped image)
|
||
|
|
return True
|
||
|
|
|
||
|
|
# Check header
|
||
|
|
if not header.startswith("data:image/"):
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not header.endswith(";base64"):
|
||
|
|
return False
|
||
|
|
|
||
|
|
image_type = header.removeprefix("data:").removesuffix(";base64")
|
||
|
|
|
||
|
|
if image_type not in support_type:
|
||
|
|
logger.info(f"Unsupported image type: {image_type}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Try to decode the avatar
|
||
|
|
try:
|
||
|
|
base64.b64decode(encoded_data, validate=True)
|
||
|
|
return True
|
||
|
|
except (binascii.Error, ValueError) as exc:
|
||
|
|
logger.info(f"Unable to decode account avatar: {exc}")
|
||
|
|
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _check_account(account: dict, account_id: str | None = None, fix_wrong: bool = False) -> bool:
|
||
|
|
"""
|
||
|
|
Check if an account is valid.
|
||
|
|
:param account:
|
||
|
|
:param account_id:
|
||
|
|
:param fix_wrong:
|
||
|
|
:return:
|
||
|
|
is_valid: bool
|
||
|
|
"""
|
||
|
|
if not isinstance(account, dict):
|
||
|
|
return False
|
||
|
|
|
||
|
|
required_strings = (
|
||
|
|
"accessToken",
|
||
|
|
"accessTokenExpiresAt",
|
||
|
|
"refreshToken",
|
||
|
|
"refreshTokenExpiresAt",
|
||
|
|
"accountID",
|
||
|
|
"type",
|
||
|
|
"username",
|
||
|
|
)
|
||
|
|
|
||
|
|
# Check all required key is exiting
|
||
|
|
if any(not isinstance(account.get(key), str) or not account[key] for key in required_strings):
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Check account id is match as provided
|
||
|
|
if account_id is not None and account["accountID"] != account_id:
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Verify profile data (Must contain uuid and name)
|
||
|
|
minecraft_profile = account.get("minecraftProfile")
|
||
|
|
if not isinstance(minecraft_profile, dict):
|
||
|
|
return False
|
||
|
|
if any(not isinstance(minecraft_profile.get(key), str) for key in ("uuid", "name")):
|
||
|
|
return False
|
||
|
|
|
||
|
|
# User properties (Left from official launcher, maybe this field will put some custom account settings in the feture
|
||
|
|
properties = account.get("userProperties")
|
||
|
|
if properties is None and fix_wrong:
|
||
|
|
account["userProperties"] = []
|
||
|
|
elif not isinstance(properties, list):
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Account avatar
|
||
|
|
avatar = account.get("avatar")
|
||
|
|
if avatar is None and fix_wrong:
|
||
|
|
account["avatar"] = ""
|
||
|
|
elif not isinstance(avatar, str):
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not is_valid_account_avatar(avatar):
|
||
|
|
return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def is_account_valid(account_data: dict, fix_wrong: bool = False) -> bool:
|
||
|
|
return _check_account(account_data, fix_wrong=fix_wrong)
|
||
|
|
|
||
|
|
|
||
|
|
def is_accounts_valid(accounts: dict, fix_wrong: bool = False, ignore_invalid_account: bool = False) -> bool:
|
||
|
|
"""
|
||
|
|
Check if accountData (contain multiple account and some settings) is valid.
|
||
|
|
:param accounts:
|
||
|
|
:param fix_wrong:
|
||
|
|
:param ignore_invalid_account:
|
||
|
|
:return:
|
||
|
|
is_valid: bool
|
||
|
|
"""
|
||
|
|
if not isinstance(accounts, dict) or not isinstance(accounts.get("accounts"), dict):
|
||
|
|
return False
|
||
|
|
|
||
|
|
account_map = accounts["accounts"]
|
||
|
|
active_id = accounts.get("activeAccountId")
|
||
|
|
|
||
|
|
if active_id is not None and not isinstance(active_id, str):
|
||
|
|
return False
|
||
|
|
|
||
|
|
invalid_ids = [
|
||
|
|
account_id
|
||
|
|
for account_id, account in account_map.items()
|
||
|
|
if not isinstance(account_id, str)
|
||
|
|
or not _check_account(account, account_id=account_id, fix_wrong=fix_wrong)
|
||
|
|
]
|
||
|
|
|
||
|
|
if invalid_ids and not ignore_invalid_account:
|
||
|
|
return False
|
||
|
|
|
||
|
|
for account_id in invalid_ids:
|
||
|
|
account_map.pop(account_id, None)
|
||
|
|
|
||
|
|
if active_id is not None and active_id not in account_map:
|
||
|
|
if fix_wrong or ignore_invalid_account:
|
||
|
|
accounts["activeAccountId"] = next(iter(account_map), None)
|
||
|
|
else:
|
||
|
|
return False
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def list_accounts(accounts: dict) -> dict:
|
||
|
|
"""
|
||
|
|
Get account map from accountData.
|
||
|
|
:param accounts:
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
value = accounts.get("accounts", {}) if isinstance(accounts, dict) else {}
|
||
|
|
return value if isinstance(value, dict) else {}
|
||
|
|
|
||
|
|
|
||
|
|
def is_account_exists(accounts: dict, account_id: str) -> bool:
|
||
|
|
"""
|
||
|
|
Check if an account exists in accountData.
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
return account_id in list_accounts(accounts)
|
||
|
|
|
||
|
|
|
||
|
|
def get_account(accounts: dict, account_id: str) -> dict:
|
||
|
|
"""
|
||
|
|
Get an account from accountData.
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
account_dict: dict
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
return list_accounts(accounts)[account_id]
|
||
|
|
except KeyError as exc:
|
||
|
|
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.") from exc
|
||
|
|
|
||
|
|
|
||
|
|
def get_current_account_id(accounts: dict) -> str | None:
|
||
|
|
"""
|
||
|
|
Get current account id from accountData.
|
||
|
|
:param accounts:
|
||
|
|
:return:
|
||
|
|
account_dict: dict
|
||
|
|
"""
|
||
|
|
return accounts.get("activeAccountId") if isinstance(accounts, dict) else None
|
||
|
|
|
||
|
|
|
||
|
|
get_active_account_id = get_current_account_id
|
||
|
|
|
||
|
|
|
||
|
|
def get_current_account(accounts: dict) -> dict | None:
|
||
|
|
"""
|
||
|
|
Get current account from accountData.
|
||
|
|
:param accounts:
|
||
|
|
:return:
|
||
|
|
account_dict: dict
|
||
|
|
"""
|
||
|
|
account_id = get_current_account_id(accounts)
|
||
|
|
|
||
|
|
return get_account(accounts, account_id) if account_id is not None else None
|
||
|
|
|
||
|
|
|
||
|
|
get_active_account = get_current_account
|
||
|
|
|
||
|
|
|
||
|
|
def set_current_account_id(accounts: dict, account_id: str | None) -> None:
|
||
|
|
"""
|
||
|
|
Set current account id
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
if account_id is not None and not is_account_exists(accounts, account_id):
|
||
|
|
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||
|
|
|
||
|
|
accounts["activeAccountId"] = account_id
|
||
|
|
|
||
|
|
|
||
|
|
set_active_account_id = set_current_account_id
|
||
|
|
|
||
|
|
|
||
|
|
def add_account(accounts: dict, account: dict, *, overwrite: bool = False) -> tuple[dict, str]:
|
||
|
|
"""
|
||
|
|
Add an account to accountData.
|
||
|
|
:param accounts:
|
||
|
|
:param account:
|
||
|
|
:param overwrite:
|
||
|
|
:return:
|
||
|
|
new_account_data: dict
|
||
|
|
new_account_id: str
|
||
|
|
"""
|
||
|
|
if not is_account_valid(account):
|
||
|
|
raise AccountException("Provided account data is not valid.")
|
||
|
|
|
||
|
|
account_id = account["accountID"]
|
||
|
|
account_map = list_accounts(accounts)
|
||
|
|
|
||
|
|
if account_id in account_map and not overwrite:
|
||
|
|
raise AccountException(f"Account id {account_id} already exists.")
|
||
|
|
|
||
|
|
account_map[account_id] = account
|
||
|
|
|
||
|
|
return accounts, account_id
|
||
|
|
|
||
|
|
|
||
|
|
def create_account(account: Account, accounts: dict | None = None) -> tuple[dict, str]:
|
||
|
|
"""
|
||
|
|
Create new account
|
||
|
|
:param account:
|
||
|
|
:param accounts:
|
||
|
|
:return:
|
||
|
|
account_dict: dict
|
||
|
|
new_account_id: str
|
||
|
|
"""
|
||
|
|
if accounts is None:
|
||
|
|
accounts = get_account_sample()
|
||
|
|
|
||
|
|
account_data = create_account_data_from_object(account)
|
||
|
|
_, account_id = add_account(accounts, account_data)
|
||
|
|
|
||
|
|
return account_data, account_id
|
||
|
|
|
||
|
|
|
||
|
|
def update_account(accounts: dict, account_id: str, account_data: dict, fix_wrong: bool = False) -> tuple[dict, str]:
|
||
|
|
"""
|
||
|
|
Update target account data value
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:param account_data:
|
||
|
|
:param fix_wrong:
|
||
|
|
:return:
|
||
|
|
updated_account_data: dict
|
||
|
|
updated_account_id: str
|
||
|
|
"""
|
||
|
|
if not is_account_exists(accounts, account_id):
|
||
|
|
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||
|
|
|
||
|
|
if not _check_account(account_data, account_id=account_id, fix_wrong=fix_wrong):
|
||
|
|
raise AccountException("Provided account data is not valid.")
|
||
|
|
|
||
|
|
accounts["accounts"][account_id] = account_data
|
||
|
|
|
||
|
|
return accounts, account_id
|
||
|
|
|
||
|
|
|
||
|
|
def remove_account(accounts: dict, account_id: str) -> None:
|
||
|
|
"""
|
||
|
|
Remove account from accountData.
|
||
|
|
INFO: If the active account ID is same as the account that provided. Active account will be set to None.
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
account_map = list_accounts(accounts)
|
||
|
|
|
||
|
|
try:
|
||
|
|
del account_map[account_id]
|
||
|
|
except KeyError as exc:
|
||
|
|
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.") from exc
|
||
|
|
|
||
|
|
if get_current_account_id(accounts) == account_id:
|
||
|
|
accounts["activeAccountId"] = next(iter(account_map), None)
|
||
|
|
|
||
|
|
|
||
|
|
def convert_account_data_to_object(accounts: dict, account_id: str) -> Account:
|
||
|
|
"""
|
||
|
|
Convert account data to Account
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
account_object: Account
|
||
|
|
"""
|
||
|
|
data = get_account(accounts, account_id)
|
||
|
|
minecraft_profile = data.get("minecraftProfile", {})
|
||
|
|
return Account(
|
||
|
|
account_id=data.get("accountID", account_id),
|
||
|
|
username=data.get("username", ""),
|
||
|
|
access_token=data.get("accessToken", ""),
|
||
|
|
refresh_token=data.get("refreshToken", ""),
|
||
|
|
access_token_expires_at=data.get("accessTokenExpiresAt", ""),
|
||
|
|
refresh_token_expires_at=data.get("refreshTokenExpiresAt", ""),
|
||
|
|
minecraft_uuid=minecraft_profile.get("uuid", ""),
|
||
|
|
minecraft_name=minecraft_profile.get("name", ""),
|
||
|
|
avatar=data.get("avatar", ""),
|
||
|
|
type=data.get("type", "msa"),
|
||
|
|
user_properties=deepcopy(data.get("userProperties", [])),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def read_account_file(account_filepath: str | Path) -> dict:
|
||
|
|
"""
|
||
|
|
Read account file
|
||
|
|
:param account_filepath:
|
||
|
|
:return:
|
||
|
|
accountData: dict
|
||
|
|
"""
|
||
|
|
path = Path(account_filepath)
|
||
|
|
if not path.exists():
|
||
|
|
raise AccountLoadException(f"Account file does not exist: {path}")
|
||
|
|
try:
|
||
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||
|
|
raise AccountLoadException(f"Unable to read account file: {exc}") from exc
|
||
|
|
if not is_accounts_valid(data):
|
||
|
|
raise AccountLoadException("Account file contains invalid account data.")
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
read_accounts_file = read_account_file
|
||
|
|
|
||
|
|
|
||
|
|
def create_or_save_account_file(accounts: dict, account_filepath: str | Path,
|
||
|
|
account_file_bak_filepath: str | Path | None = None,
|
||
|
|
overwrite: bool = True,
|
||
|
|
create_backup: bool = True) -> None:
|
||
|
|
"""
|
||
|
|
Create or save account file
|
||
|
|
:param accounts:
|
||
|
|
:param account_filepath:
|
||
|
|
:param account_file_bak_filepath:
|
||
|
|
:param overwrite:
|
||
|
|
:param create_backup:
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
if not is_accounts_valid(accounts):
|
||
|
|
raise AccountSaveException("Refusing to save invalid account data.")
|
||
|
|
|
||
|
|
path = Path(account_filepath)
|
||
|
|
if path.exists() and not overwrite:
|
||
|
|
raise AccountSaveException(f"Account file already exists: {path}")
|
||
|
|
backup = (
|
||
|
|
Path(account_file_bak_filepath)
|
||
|
|
if account_file_bak_filepath is not None
|
||
|
|
else path.with_suffix(path.suffix + ".bak")
|
||
|
|
)
|
||
|
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||
|
|
|
||
|
|
try:
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
temporary.write_text(
|
||
|
|
json.dumps(accounts, indent=4, ensure_ascii=False), encoding="utf-8"
|
||
|
|
)
|
||
|
|
if create_backup and path.exists():
|
||
|
|
if backup.exists():
|
||
|
|
backup.unlink()
|
||
|
|
os.replace(path, backup)
|
||
|
|
os.replace(temporary, path)
|
||
|
|
except OSError as exc:
|
||
|
|
if create_backup and backup.exists() and not path.exists():
|
||
|
|
os.replace(backup, path)
|
||
|
|
try:
|
||
|
|
temporary.unlink(missing_ok=True)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
raise AccountSaveException(f"Unable to save account file: {exc}") from exc
|
||
|
|
|
||
|
|
def get_account_avatar(accounts: dict, account_id: str) -> str | None:
|
||
|
|
"""
|
||
|
|
Get account avatar fron raw data
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:return:
|
||
|
|
avatar_data_url: str | None
|
||
|
|
"""
|
||
|
|
account = get_account(accounts, account_id)
|
||
|
|
|
||
|
|
if "avatar" not in account:
|
||
|
|
raise AccountKeyNotFoundException(f"Account {account_id} missing 'avatar' field")
|
||
|
|
|
||
|
|
return account.get("avatar", None)
|
||
|
|
|
||
|
|
def save_account_avatar(accounts: dict, account_id: str, avatar_raw: bytes | str) -> None:
|
||
|
|
"""
|
||
|
|
Save account avatar to account data
|
||
|
|
:param accounts:
|
||
|
|
:param account_id:
|
||
|
|
:param avatar_raw: Set it to empty string as default
|
||
|
|
:return:
|
||
|
|
"""
|
||
|
|
if not is_account_exists(accounts, account_id):
|
||
|
|
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||
|
|
|
||
|
|
if isinstance(avatar_raw, bytes):
|
||
|
|
try:
|
||
|
|
avatar_raw = bytes_to_data_url(avatar_raw)
|
||
|
|
except (TypeError, ValueError) as exc:
|
||
|
|
raise AccountSaveException("Invalid account avatar bytes.") from exc
|
||
|
|
|
||
|
|
if not is_valid_account_avatar(avatar_raw):
|
||
|
|
raise AccountSaveException(f"Invalid account avatar: {avatar_raw}")
|
||
|
|
|
||
|
|
accounts["accounts"][account_id]["avatar"] = avatar_raw
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
create_or_save_accounts_file = create_or_save_account_file
|