Add account manager and the account page is now fully functional.
Fix "Missing javaVersion" error when launching legacy Minecraft. Add settings page. (But is not fully implemented yet)
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import platform
|
||||
@@ -304,4 +306,24 @@ def is_valid_zipfile(path: Path) -> bool:
|
||||
try:
|
||||
return zipfile.is_zipfile(path)
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def bytes_to_data_url(image_data: bytes, mime_type: str = "image/png",) -> str:
|
||||
"""
|
||||
Convert raw image bytes to a Base64 Data URL.
|
||||
:param image_data:
|
||||
:param mime_type:
|
||||
:return:
|
||||
data_url: str
|
||||
"""
|
||||
if not isinstance(image_data, bytes):
|
||||
raise TypeError("image_data must be bytes")
|
||||
|
||||
if not image_data:
|
||||
raise ValueError("image_data cannot be empty")
|
||||
|
||||
if not isinstance(mime_type, str) or not mime_type.startswith("image/"):
|
||||
raise ValueError("mime_type must be a valid image MIME type")
|
||||
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
@@ -86,4 +86,28 @@ class ProfileLoadException(LauncherException):
|
||||
user_message = "Unable to load profile. Profile may corrupted"
|
||||
|
||||
class ProfileKeyNotFoundException(LauncherException):
|
||||
user_message = "Specified profile key not found. Profile may corrupted."
|
||||
user_message = "Specified profile key not found. Profile may corrupted."
|
||||
|
||||
class AccountException(LauncherException):
|
||||
user_message = "An error occurred while processing account data."
|
||||
|
||||
class AccountSaveException(AccountException):
|
||||
user_message = "Unable to save account data. Try again later."
|
||||
|
||||
class AccountLoadException(AccountException):
|
||||
user_message = "Unable to load account data. The account file may be corrupted."
|
||||
|
||||
class AccountKeyNotFoundException(AccountException):
|
||||
user_message = "Specified account was not found."
|
||||
|
||||
class AuthenticationException(LauncherException):
|
||||
user_message = "Authentication failed."
|
||||
|
||||
class AuthenticationConnectException(LauncherException):
|
||||
user_message = "An error occurred while connecting to authentication server."
|
||||
|
||||
class AuthenticationSessionException(LauncherException):
|
||||
user_message = "Authentication failed. Session may expire."
|
||||
|
||||
class ThirdPartyAPIException(LauncherException):
|
||||
user_message = "An error occurred while fetching third party API."
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Store all function for microsoft authentication
|
||||
|
||||
WARNING: This module is vibed! With code reviewed!
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from core_lib.common.exception import AuthenticationSessionException, AuthenticationConnectException, \
|
||||
AuthenticationException
|
||||
|
||||
MICROSOFT_DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||
XBOX_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"
|
||||
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"
|
||||
MINECRAFT_LOGIN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"
|
||||
MINECRAFT_ENTITLEMENTS_URL = "https://api.minecraftservices.com/entitlements/mcstore"
|
||||
MINECRAFT_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"
|
||||
|
||||
logger = logging.getLogger("Launcher.CoreLib")
|
||||
|
||||
def auth_exception_handler(request_handler: Callable, request_type: str = "without_token", stage: str = "authentication",):
|
||||
"""
|
||||
Handle authentication exceptions
|
||||
:param stage:
|
||||
:param request_handler:
|
||||
:param request_type: Set this will change exception type when an error occurs. ("without_token" for process that
|
||||
don't use token, "with_token" for process that use token. If with token, exception usually throw
|
||||
AuthenticationSessionException. If not, throw AuthenticationException instead)
|
||||
:return:
|
||||
"""
|
||||
exception_type = (
|
||||
AuthenticationSessionException
|
||||
if request_type == "with_token"
|
||||
else AuthenticationException
|
||||
)
|
||||
|
||||
try:
|
||||
return request_handler()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else "unknown"
|
||||
response_data = {}
|
||||
if e.response is not None:
|
||||
try:
|
||||
value = e.response.json()
|
||||
if isinstance(value, dict):
|
||||
response_data = value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
xerr = response_data.get("XErr")
|
||||
try:
|
||||
xerr = int(xerr) if xerr is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Below are the xsts error code suggestions. Why return that value (error is integer not string) ?
|
||||
# Maybe it makes international support more easily?
|
||||
xsts_suggestions = {
|
||||
2148916233: "This Microsoft account does not have an Xbox profile. Sign in at xbox.com and create one, then try again.",
|
||||
2148916235: "Xbox Live is not available for this account's country or region.",
|
||||
2148916236: "This account must complete adult verification on the Xbox website.",
|
||||
2148916237: "This account must complete age verification on the Xbox website.",
|
||||
2148916238: "This is a child account. An adult must add it to a Microsoft family and allow Xbox access.",
|
||||
}
|
||||
if xerr in xsts_suggestions:
|
||||
raise exception_type(
|
||||
f"{stage} failed with HTTP {status}, XErr {xerr}",
|
||||
user_message=f"{stage} failed (Xbox error {xerr}). {xsts_suggestions[xerr]}",
|
||||
) from e
|
||||
|
||||
server_message = response_data.get("Message") or response_data.get("error_description")
|
||||
# This suggestions can make user happy instead of seeing an HTTP error code
|
||||
suggestions = {
|
||||
400: "The authentication request or session was rejected.",
|
||||
401: "The login session is invalid or expired. Sign in again.",
|
||||
403: "This account is not allowed to complete this step. Check Xbox privacy settings and account restrictions."
|
||||
" Or check the client id has authorized to use Minecraft API.",
|
||||
429: "The authentication service received too many requests. Wait a moment and try again.",
|
||||
}
|
||||
|
||||
suggestion = suggestions.get(
|
||||
status,
|
||||
"The authentication service returned an unexpected response. Try again later.",
|
||||
)
|
||||
if server_message:
|
||||
suggestion = f"{suggestion} Server message: {server_message}"
|
||||
raise exception_type(f"{stage} failed with HTTP status {status}: {e}",
|
||||
user_message=f"{stage} failed (HTTP {status}). {suggestion}",
|
||||
) from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise AuthenticationConnectException(
|
||||
f"Unable to connect during {stage}: {e}",
|
||||
user_message=f"{stage} could not connect to the authentication server. Check your internet connection, proxy, and firewall.",
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise AuthenticationConnectException(
|
||||
f"Request timed out during {stage}: {e}",
|
||||
user_message=f"{stage} timed out. Check your connection and try again later.",
|
||||
) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise exception_type(
|
||||
f"Request error during {stage}: {e}",
|
||||
user_message=f"{stage} failed because the authentication request could not be completed: {e}",
|
||||
) from e
|
||||
except AuthenticationException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise exception_type(
|
||||
f"Unexpected error during {stage}: {traceback.format_exc()}",
|
||||
user_message=f"{stage} failed because the server returned invalid or unexpected data.",
|
||||
) from e
|
||||
|
||||
|
||||
def get_device_code(client_id: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get xbox live device code
|
||||
:param client_id:
|
||||
:return:
|
||||
device_code: str
|
||||
json_data: dict
|
||||
"""
|
||||
def request_handler():
|
||||
body = {
|
||||
"client_id": client_id,
|
||||
"scope": "XboxLive.signin offline_access",
|
||||
}
|
||||
# Microsoft OAuth endpoints require form-encoded request bodies.
|
||||
r = requests.post(MICROSOFT_DEVICE_CODE_URL, data=body)
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()
|
||||
|
||||
data = auth_exception_handler(request_handler, stage="Requesting a Microsoft sign-in code")
|
||||
|
||||
if not data.get("device_code"):
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing device_code",
|
||||
user_message="Microsoft did not return a sign-in code. Check the client ID or update the launcher.",
|
||||
)
|
||||
|
||||
return data["device_code"], data
|
||||
|
||||
def get_microsoft_token(client_id: str, device_code: str, interval: int, expires_in: float, cancelled=None) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Get microsoft token (with login wait)
|
||||
:param client_id:
|
||||
:param device_code:
|
||||
:param interval:
|
||||
:param expires_in:
|
||||
:param cancelled:
|
||||
:return:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(MICROSOFT_TOKEN_URL, data={
|
||||
"client_id": client_id,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
})
|
||||
|
||||
return r.status_code, r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, str, dict]:
|
||||
access_token = result.get("access_token")
|
||||
refresh_token = result.get("refresh_token")
|
||||
if not access_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing access_token",
|
||||
user_message="Microsoft approved the sign-in but did not return an access token. Please sign in again.",
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing refresh_token",
|
||||
user_message="Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.",
|
||||
)
|
||||
|
||||
return access_token, refresh_token, result
|
||||
|
||||
deadline = time.monotonic() + expires_in
|
||||
while time.monotonic() < deadline:
|
||||
# cancel callback
|
||||
if cancelled and cancelled():
|
||||
raise InterruptedError("Login cancelled.")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
status, data = auth_exception_handler(_request_handler, stage="Waiting for Microsoft sign-in approval")
|
||||
|
||||
if status == 200:
|
||||
return verify_data(data)
|
||||
error = data.get("error")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval += 5
|
||||
continue
|
||||
|
||||
description = data.get("error_description") or data.get("error") or "No details were provided"
|
||||
raise AuthenticationException(
|
||||
f"Microsoft token request failed with HTTP {status}: {description}",
|
||||
user_message=f"Microsoft sign-in was rejected (HTTP {status}): {description}",
|
||||
)
|
||||
|
||||
raise TimeoutError("Microsoft sign-in code expired before login was completed. Start the sign-in process again.")
|
||||
|
||||
def get_xbox_token_and_user_hash(access_token: str) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Get xbox live token and user hash
|
||||
:param access_token: Microsoft account access token
|
||||
:return:
|
||||
xbox_token: str
|
||||
user_hash: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(XBOX_AUTH_URL, json={
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": f"d={access_token}",
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT",
|
||||
}, headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, str, dict]:
|
||||
xbox_token = result.get("Token")
|
||||
user_hash = None
|
||||
|
||||
try:
|
||||
user_hash = result.get("DisplayClaims", {}).get("xui", {})[0].get("uhs")
|
||||
except Exception as e:
|
||||
logger.error("Unable to get user hash from data {}: {}".format(result, e))
|
||||
|
||||
if not xbox_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing xbox_token",
|
||||
user_message="Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.",
|
||||
)
|
||||
|
||||
if not user_hash:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing user_hash",
|
||||
user_message="Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.",
|
||||
)
|
||||
|
||||
return xbox_token, user_hash, result
|
||||
|
||||
data = auth_exception_handler(_request_handler, stage="Signing in to Xbox Live")
|
||||
return verify_data(data)
|
||||
|
||||
def get_xsts_token(xbox_token: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get xsts token
|
||||
:param xbox_token:
|
||||
:return:
|
||||
xsts_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(XSTS_AUTH_URL, json={
|
||||
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbox_token]},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT",
|
||||
}, headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, dict]:
|
||||
xsts_token = result.get("Token")
|
||||
|
||||
if not xsts_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing xsts_token",
|
||||
user_message="Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.",
|
||||
)
|
||||
|
||||
return xsts_token, result
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Authorizing the Xbox session for Minecraft",
|
||||
)
|
||||
|
||||
return verify_data(data)
|
||||
|
||||
def get_minecraft_access_token(user_hash: str, xsts_token: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get minecraft access token
|
||||
:param user_hash: Xbox live user hash
|
||||
:param xsts_token: XSTS token
|
||||
Above param can get from get_xbox_token_and_user_hash and get_xsts_token
|
||||
:return:
|
||||
access_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(MINECRAFT_LOGIN_URL, json={"identityToken": f"XBL3.0 x={user_hash};{xsts_token}"},
|
||||
headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, dict]:
|
||||
access_token = result.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing access token",
|
||||
user_message="Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.",
|
||||
)
|
||||
|
||||
return access_token, result
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Signing in to Minecraft services",
|
||||
)
|
||||
|
||||
return verify_data(data)
|
||||
|
||||
|
||||
def get_account_entitlements(minecraft_access_token: str) -> dict:
|
||||
"""
|
||||
Get minecraft account entitlements
|
||||
:param minecraft_access_token:
|
||||
:return:
|
||||
"""
|
||||
def _request_handler():
|
||||
response = requests.get(
|
||||
MINECRAFT_ENTITLEMENTS_URL,
|
||||
headers={"Authorization": f"Bearer {minecraft_access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Checking Minecraft ownership",
|
||||
)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
|
||||
raise AuthenticationSessionException(
|
||||
"Minecraft entitlement response is missing the items list",
|
||||
user_message="Minecraft services returned an invalid ownership response. Please sign in again or try later.",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_minecraft_profile(minecraft_access_token: str) -> dict:
|
||||
"""
|
||||
Get minecraft profile
|
||||
:param minecraft_access_token:
|
||||
:return:
|
||||
profile_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
response = requests.get(
|
||||
MINECRAFT_PROFILE_URL,
|
||||
headers={"Authorization": f"Bearer {minecraft_access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Loading the Minecraft profile",
|
||||
)
|
||||
if not isinstance(data, dict) or not data.get("id") or not data.get("name"):
|
||||
raise AuthenticationSessionException(
|
||||
"Minecraft profile response is missing id or name",
|
||||
user_message="Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def has_minecraft_license(entitlements: dict) -> bool:
|
||||
"""
|
||||
Check if provided account entitlements have minecraft license
|
||||
:param entitlements:
|
||||
:return:
|
||||
found: bool
|
||||
"""
|
||||
if not isinstance(entitlements, dict):
|
||||
raise AuthenticationException(
|
||||
f"Expected Minecraft entitlements to be a dictionary, got {type(entitlements).__name__}",
|
||||
user_message="Unable to verify Minecraft ownership because the ownership response is invalid.",
|
||||
)
|
||||
|
||||
items = entitlements.get("items")
|
||||
if not isinstance(items, list):
|
||||
raise AuthenticationException(
|
||||
"Minecraft entitlement response is missing a valid items list",
|
||||
user_message="Unable to verify Minecraft ownership because the ownership list is missing or invalid.",
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and item.get("name") in {"game_minecraft", "product_minecraft"}
|
||||
for item in items
|
||||
)
|
||||
|
||||
def refresh_microsoft_token(client_id: str, refresh_token: str) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Use existing microsoft refresh token to get new (or extend the expires time) refresh token
|
||||
Returns:
|
||||
microsoft_access_token,
|
||||
new_refresh_token,
|
||||
response_data
|
||||
"""
|
||||
|
||||
def request_handler():
|
||||
response = requests.post(
|
||||
MICROSOFT_TOKEN_URL,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "XboxLive.signin offline_access",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
result = auth_exception_handler(
|
||||
request_handler,
|
||||
request_type="with_token",
|
||||
stage="Refreshing Microsoft credentials",
|
||||
)
|
||||
|
||||
access_token = result.get("access_token")
|
||||
new_refresh_token = result.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
raise AuthenticationSessionException(
|
||||
"Refresh response missing access_token",
|
||||
user_message=(
|
||||
"Microsoft could not refresh this account. "
|
||||
"Please sign in again."
|
||||
),
|
||||
)
|
||||
|
||||
# Return original refresh token if new token is not show up in response
|
||||
return (
|
||||
access_token,
|
||||
new_refresh_token or refresh_token,
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \
|
||||
ProfileLoadException
|
||||
|
||||
@@ -434,6 +436,14 @@ def duplicate_profile(profiles: dict, profile_id: str, new_name: str | None=None
|
||||
)
|
||||
|
||||
def convert_profile_data_to_object(profiles: dict, profile_id: str) -> Profile:
|
||||
"""
|
||||
Convert profiles data to object
|
||||
:param profiles:
|
||||
:param profile_id:
|
||||
:return:
|
||||
profiles: dict
|
||||
profile_id: str
|
||||
"""
|
||||
profile = get_profile(profiles, profile_id)
|
||||
|
||||
created: str | datetime.datetime = profile.get("created", datetime.datetime.now())
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Contains some function that deal with 3rd party api
|
||||
"""
|
||||
import requests
|
||||
|
||||
from core_lib.common.exception import ThirdPartyAPIException
|
||||
|
||||
MC_HEADS_HEAD_ENDPOINT = "https://api.mcheads.org/head/{username}/{size}"
|
||||
|
||||
def get_minecraft_head_url(username: str, size: int=256) -> str:
|
||||
if not 256 >= size >= 32:
|
||||
raise ValueError("size must be between 256 and 32")
|
||||
|
||||
return MC_HEADS_HEAD_ENDPOINT.format(username=username, size=size)
|
||||
|
||||
def get_minecraft_head(username: str, size: int=256, timeout: float=10.0) -> bytes:
|
||||
"""
|
||||
Get minecraft head
|
||||
:param username:
|
||||
:param size:
|
||||
:return:
|
||||
head_data: bytes
|
||||
"""
|
||||
url = get_minecraft_head_url(username, size)
|
||||
|
||||
try:
|
||||
r = requests.get(url, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status_code = e.response.status_code
|
||||
|
||||
if status_code == 404:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP 404 when fetching api {}. Did the provider still running this service ?".format(MC_HEADS_HEAD_ENDPOINT),
|
||||
user_message="Unable to fetch third party API. Provider may no longer exist. Try updating the launcher."
|
||||
)
|
||||
elif status_code == 500:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP 500 when fetching api {}. Did the head size is correct?".format(MC_HEADS_HEAD_ENDPOINT),
|
||||
user_message="Unable to fetch third party API. Try updating the launcher.",
|
||||
)
|
||||
else:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP {} unhandled error. Result: {}".format(status_code, e.response.text),
|
||||
user_message="Unable to fetch third party API. Unknown HTTP Error: {}".format(status_code),
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ThirdPartyAPIException(
|
||||
"An error occurred while fetching third party API: {}".format(e),
|
||||
user_message="Unable to fetch third party API. Try updating the launcher.",
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ def get_version_manifest(manifest_url=MOJANG_VERSION_MANIFEST_V2, timeout=10) ->
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4)))
|
||||
# logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4)))
|
||||
return data
|
||||
except ValueError as e:
|
||||
raise VersionManifestFetchException(
|
||||
@@ -180,10 +180,10 @@ def get_all_versions(version_manifest: dict, is_snapshot=False, include_all_type
|
||||
filtered = []
|
||||
|
||||
for ver in all_vers:
|
||||
ver_id = ver.get("id", None)
|
||||
# ver_id = ver.get("id", None)
|
||||
ver_type = ver.get("type")
|
||||
|
||||
logger.debug("Ver: {}, Type: {}".format(ver_id, ver_type))
|
||||
# logger.debug("Ver: {}, Type: {}".format(ver_id, ver_type))
|
||||
|
||||
if ver_type == target_type or include_all_type:
|
||||
filtered.append(ver)
|
||||
|
||||
+25
-3
@@ -1,11 +1,12 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt
|
||||
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor
|
||||
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget
|
||||
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor, QImage
|
||||
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget, QDialog
|
||||
|
||||
|
||||
def move_window_to_center(window: QMainWindow):
|
||||
def move_window_to_center(window: QMainWindow | QDialog) -> None:
|
||||
main_screen = QApplication.primaryScreen()
|
||||
center = QScreen.availableGeometry(main_screen).center()
|
||||
geometry = window.frameGeometry()
|
||||
@@ -102,4 +103,25 @@ def add_icon_padding(icon: QIcon, icon_size=32, padding=6) -> QIcon:
|
||||
|
||||
return QIcon(canvas)
|
||||
|
||||
def data_url_to_qt_image(data_url: str) -> QImage:
|
||||
try:
|
||||
header, encoded_data = data_url.split(",", 1)
|
||||
except ValueError:
|
||||
raise ValueError("Not a valid data url")
|
||||
|
||||
if ";base64" not in header:
|
||||
raise ValueError("Unsupported data url. Supported data urls are base64")
|
||||
|
||||
try:
|
||||
image_data = base64.b64decode(encoded_data, validate=True)
|
||||
except ValueError as error:
|
||||
raise ValueError("Invalid image data") from error
|
||||
|
||||
image = QImage.fromData(image_data)
|
||||
|
||||
if image.isNull():
|
||||
raise ValueError("Qt does not support this image")
|
||||
|
||||
return image
|
||||
|
||||
# ======================== AI generated END ========================
|
||||
Reference in New Issue
Block a user