diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf06f65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wei Hong + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index ce63a83..5a9b576 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ # TestLauncher -# There's no stable branch because this project is still under development. \ No newline at end of file +# Still thinking about its name... +# There's no stable branch because this project is still under development. + + +## How to use ? + +#### Below are this project dependencies. Install them before use the launcher. + +* `git` : [Website](https://git-scm.com/) +* `uv` : [Install Guide](https://docs.astral.sh/uv/getting-started/installation/) +* `python` : [Website](https://python.org) (Version 3.12 or above) + +### Installation + +1. Clone the project : +``` +git clone https://repo.weispace.net/wei/Launcher.git TestLauncher +cd TestLauncher +``` + +2. Sync required packages : +``` +uv sync +``` + +3. Run the launcher +> Is recommended to use `--work-dir` argument to change working directory. (If you don't like launcher mess up the +> project folder) +```aiignore +mkdir launcherDir +uv run python main.py --work-dir +``` + +## Troubleshooting +#### Unable to log in Microsoft account. Launcher throw error "Signing in to Minecraft services failed (HTTP 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." + +This launcher's client id haven't registered to [Minecraft API](https://forms.cloud.microsoft/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR-ajEQ1td1ROpz00KtS8Gd5UNVpPTkVLNFVROVQxNkdRMEtXVjNQQjdXVC4u). +You may need to replace the client id that has permission to access Minecraft API in settings page (Settings>Account>Client ID) \ No newline at end of file diff --git a/__init__.py b/__init__.py index dcdf65e..cfdbb32 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,7 @@ __description__ = """ A *Minecraft* launcher that I'm still thinking about its name. I made this due to the official Minecraft Launcher is too huge and laggy. I need to wait it over 1 min to launch -game, and I also think that other launchers are too complicated for most people to use. +game, and I think launcher must be simple and lightweight to make user to feel is easy to use. This launcher should adheres to the following rules: @@ -13,7 +13,6 @@ This launcher should adheres to the following rules: 2: Be complex at some point (Yeah this rule looks opposite to the first rule) 3: Be cool and fun 4: No AI slop (Nobody wants to read spaghetti code that made by AI) -5: Freedom just like the bird -6: Don't repeat yourself (originated from Django) +5: Freedom just like the bird (This project use MIT license) """ __website__ = "https://repo.weispace.net/wei/Launcher" \ No newline at end of file diff --git a/app.py b/app.py index 52e7126..9873563 100644 --- a/app.py +++ b/app.py @@ -1,29 +1,28 @@ -import logging -import os import sys from pathlib import Path from typing import Callable -from PySide6 import QtWidgets from PySide6.QtCore import QSize -from PySide6.QtGui import QIcon, Qt, QPixmap -from PySide6.QtWidgets import QApplication, QWidget, QStyle, QToolButton +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtWidgets import QApplication, QStyle from constant import LAUNCHER_VERSION from core_lib.qt.hack import move_window_to_center, is_light_theme, \ recolor_icon from core_lib.qt import messagebox from core_lib.launcher.object import Launcher as LauncherObject +from manager.account import AccountManager from manager.game import GameManager from manager.profile import ProfileManager from manager.widgets import WidgetManager from window import LauncherMainWindow class Launcher(QApplication, LauncherObject): - def __init__(self, logger): + def __init__(self, logger, debug: bool = False): super().__init__() # logger self.logger = logger + self.debug = debug # dirs self.program_dir = Path(__file__).parent @@ -31,10 +30,10 @@ class Launcher(QApplication, LauncherObject): # Widget (Signal, Event) self.widget_manager = WidgetManager() - self.game_manager = GameManager(self) - # Manager self.profile_manager = ProfileManager(self, self.widget_manager) + self.account_manager = AccountManager(self, self.widget_manager) + self.game_manager = GameManager(self) # Window config self.setApplicationName("TestLauncher") @@ -51,15 +50,15 @@ class Launcher(QApplication, LauncherObject): self.logger.error("No icon found.") def exec(self): - self.main_window.show() - self.main_window.toolbar.update_all() - # init managers self.profile_manager.profile_load.emit() + self.account_manager.account_load.emit() + self.main_window.show() + self.main_window.toolbar.update_all() self.exec_() - def get_icon(self, path: Path): + def get_icon(self, path: Path, no_color_change: bool = False) -> QIcon: icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) if not path.exists() or not path.is_file(): messagebox.error( @@ -71,7 +70,7 @@ class Launcher(QApplication, LauncherObject): return icon try: - if is_light_theme() and path.name.endswith(".png"): + if is_light_theme() and path.name.endswith(".png") and not no_color_change: icon = recolor_icon( path ) diff --git a/constant.py b/constant.py index 13e758a..ec27884 100644 --- a/constant.py +++ b/constant.py @@ -1,2 +1,10 @@ -LAUNCHER_VERSION = "0.0.3-alpha" -MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" \ No newline at end of file +LAUNCHER_VERSION = "0.0.4-alpha" +LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)" +MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" +CLIENT_ID = "30cbb657-edab-4d06-9fb7-cf5734a40088" +MICROSOFT_VERIFY_ENDPOINT = "https://www.microsoft.com/link" +LAUNCHER_USER_AGENT = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION} (Powered by request module)" + +ACCESS_TOKEN_KEYWORD = "accessToken" +REFRESH_TOKEN_KEYWORD = "refreshToken" +SYSTEM_KEYRING_KEYWORD = "SYSTEM_CREDENTIALS" \ No newline at end of file diff --git a/core_lib/common/common.py b/core_lib/common/common.py index eceaa53..c8cc60b 100644 --- a/core_lib/common/common.py +++ b/core_lib/common/common.py @@ -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 \ No newline at end of file + 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}" \ No newline at end of file diff --git a/core_lib/common/exception.py b/core_lib/common/exception.py index 5fc2f7c..3dbf6e0 100644 --- a/core_lib/common/exception.py +++ b/core_lib/common/exception.py @@ -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." \ No newline at end of file + 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." \ No newline at end of file diff --git a/core_lib/game/account.py b/core_lib/game/account.py new file mode 100644 index 0000000..cb28697 --- /dev/null +++ b/core_lib/game/account.py @@ -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 diff --git a/core_lib/game/authentication.py b/core_lib/game/authentication.py new file mode 100644 index 0000000..50d4c7d --- /dev/null +++ b/core_lib/game/authentication.py @@ -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, + ) + diff --git a/core_lib/game/profile.py b/core_lib/game/profile.py index 03bccb5..9c8ff8a 100644 --- a/core_lib/game/profile.py +++ b/core_lib/game/profile.py @@ -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()) diff --git a/core_lib/game/third_party.py b/core_lib/game/third_party.py new file mode 100644 index 0000000..19302c8 --- /dev/null +++ b/core_lib/game/third_party.py @@ -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.", + ) + diff --git a/core_lib/game/version_info.py b/core_lib/game/version_info.py index a51f20b..9510c83 100644 --- a/core_lib/game/version_info.py +++ b/core_lib/game/version_info.py @@ -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) diff --git a/core_lib/qt/hack.py b/core_lib/qt/hack.py index 0046a7f..8475eb5 100644 --- a/core_lib/qt/hack.py +++ b/core_lib/qt/hack.py @@ -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 ======================== \ No newline at end of file diff --git a/main.py b/main.py index 52475a6..6f22e77 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,17 @@ +# Nuitka build configuration. The output mode is selected on the command line +# so the same configuration can be used for both standalone and onefile builds. +# nuitka-project: --enable-plugin=pyside6 +# nuitka-project: --include-data-dir={MAIN_DIRECTORY}/resources=resources +# nuitka-project: --output-dir={MAIN_DIRECTORY}/build +# nuitka-project: --output-filename=TestLauncher +# nuitka-project: --product-name=TestLauncher +# nuitka-project: --file-description=TestLauncher Minecraft Launcher +# nuitka-project: --copyright=Copyright (c) wei +# nuitka-project: --assume-yes-for-downloads +# nuitka-project-if: {OS} == "Windows": +# nuitka-project: --windows-console-mode=disable +# nuitka-project: --windows-icon-from-ico={MAIN_DIRECTORY}/resources/icons/icon.png + import os import sys import argparse @@ -50,7 +64,7 @@ def main(): logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level) # Start (the real) launcher - app = Launcher(logger) + app = Launcher(logger, debug=debug) sys.exit(app.exec()) diff --git a/manager/account.py b/manager/account.py new file mode 100644 index 0000000..1d15919 --- /dev/null +++ b/manager/account.py @@ -0,0 +1,1194 @@ +""" +AccountManager + +Just a simple Minecraft account manager +""" +from __future__ import annotations + +import copy +import datetime +from dataclasses import dataclass, field +from pathlib import Path + +import keyring +from PySide6.QtCore import QObject, Signal, QThread, QSize, Qt +from PySide6.QtGui import QPixmap, QImage +from PySide6.QtWidgets import QMessageBox +from keyring.errors import KeyringError, PasswordDeleteError + +from constant import LAUNCHER_NAME, CLIENT_ID, ACCESS_TOKEN_KEYWORD, REFRESH_TOKEN_KEYWORD, SYSTEM_KEYRING_KEYWORD +from core_lib.common.exception import AccountException, AccountLoadException, AccountSaveException, LauncherException, \ + ThirdPartyAPIException +from core_lib.game.account import ( + Account, + convert_account_data_to_object, + create_account as create_account_raw, + create_or_save_account_file, + get_account_sample, + get_current_account_id as get_current_account_id_raw, + is_account_exists, + is_accounts_valid, + list_accounts as list_accounts_raw, + read_account_file, + remove_account as remove_account_raw, + save_account_avatar, + set_current_account_id as set_current_account_id_raw, + update_account as update_account_raw, +) +from core_lib.game.authentication import get_xbox_token_and_user_hash, get_microsoft_token, get_xsts_token, \ + get_minecraft_access_token, get_account_entitlements, get_minecraft_profile, has_minecraft_license, get_device_code +from core_lib.game.third_party import get_minecraft_head +from core_lib.qt.hack import data_url_to_qt_image +from manager.widgets import AskForRequest + + +DEFAULT_ACCOUNT_RELATIVE_PATH = Path("accounts.json") +SERVICE_NAME = f"{LAUNCHER_NAME} Account Manager" + +@dataclass(frozen=True) +class AccountSummary: + display_name: str + id: str + username: str + minecraft_uuid: str + minecraft_name: str + avatar: str + account_type: str + + +@dataclass(frozen=True) +class AccountDetail(AccountSummary): + access_token: str = field(repr=False) + refresh_token: str = field(repr=False) + access_token_expires_at: str | datetime.datetime + refresh_token_expires_at: str | datetime.datetime + user_properties: list + + +@dataclass(frozen=True) +class CreateAccountRequest: + account_id: str + username: str + access_token: str = field(repr=False) + refresh_token: str = field(repr=False) + access_token_expires_at: str | datetime.datetime + refresh_token_expires_at: str | datetime.datetime + minecraft_uuid: str = "" + minecraft_name: str = "" + avatar: str = "" + account_type: str = "msa" + user_properties: list = field(default_factory=list) + + +class _AccountUnsetType: + pass + + +_UNSET = _AccountUnsetType() + + +@dataclass(frozen=True) +class UpdateAccountRequest: + username: str | None = None + access_token: str | None = field(default=None, repr=False) + refresh_token: str | None = field(default=None, repr=False) + access_token_expires_at: str | datetime.datetime | None = None + refresh_token_expires_at: str | datetime.datetime | None = None + minecraft_uuid: str | None = None + minecraft_name: str | None = None + avatar: str | None = None + account_type: str | None = None + user_properties: list | _AccountUnsetType = _UNSET + + +@dataclass(frozen=True) +class LaunchAccount: + account_id: str + player_name: str + minecraft_uuid: str + access_token: str = field(repr=False) + user_type: str = "msa" + user_properties: list = field(default_factory=list) + + +@dataclass(frozen=True) +class ValidationResult: + errors: dict[str, str] + + @property + def is_valid(self) -> bool: + return not self.errors + + +class LoginSignal(QObject): + """Stable login signals exposed by AccountManager to UI consumers.""" + + device_code_ready = Signal(dict) + login_succeeded = Signal(dict) + login_failed = Signal(str) + finished = Signal() + + +class HeadSignal(QObject): + """Signals emitted after an account head background request.""" + + updated = Signal(str) + failed = Signal(str, str) + + +class AccountManager(QObject): + """ + This manager pack up everything (or most) of the api that provided by core_lib. And make it more easily to use. + + A similar version of the Profile Manager! + """ + account_load = Signal() + accounts_changed = Signal() + account_created = Signal(str) + account_updated = Signal(str) + account_removed = Signal(str) + current_account_changed = Signal(object) # str | None + error_occurred = Signal(str, str) + replace_relative_path = Signal(str) + + def __init__(self, app, widget_manager): + super().__init__(app) + self.app = app + self.widget_manager = widget_manager + + self.accounts: dict = {} + self._path: Path | None = None + self._is_modified = False + + # For login/refresh token use + self.login_signal = LoginSignal(self) + self.login_thread: QThread | None = None + self.login_worker: LoginWorker | None = None + + # Account head downloads. Keep thread and worker references alive until + # QThread.finished so Qt does not destroy a running thread. + self.head_signal = HeadSignal(self) + self._head_jobs: dict[str, tuple[QThread, HeadWorker]] = {} + + self.account_load.connect(self.load) + + # Flags (For settings page) + self.use_system_keyring = True + + # + # Account File (aka AccountData) Management + # + @property + def default_account_path(self) -> Path: + return Path(self.app.work_dir, DEFAULT_ACCOUNT_RELATIVE_PATH) + + def _show_error(self, message: str, code: str = "account_error") -> None: + self.error_occurred.emit(code, message) + self.widget_manager.signal.show_error_message.emit(message) + + # File lifetime + def create_accounts(self, path: str | Path | None = None) -> bool: + """ + Create accountData + :param path: + :return: + status_code: bool + """ + path = Path(path) if path is not None else self.default_account_path + + try: + create_or_save_account_file(get_account_sample(), path, create_backup=False) + return True + except AccountSaveException as exc: + self._show_error(f"Unable to create account file: {exc.user_message}", "create_failed") + except Exception as exc: + self._show_error(f"Unable to create account file: {exc}", "create_failed") + return False + + def load(self, path: str | Path | None = None, return_value: bool = False) -> tuple[bool, dict | None]: + """ + Load accountData from disk + :param path: Path of the accountData. If not set, use self.default_account_path + :param return_value: Return full copy of the account file + :return: + status_code: bool + account_data: dict (Optional) + """ + path = Path(path) if path is not None else self.default_account_path + + try: + # Ask the user whether to save the existing record + if self._is_modified: + request = AskForRequest( + "AccountManager", + "Would you like to save the existing accounts before reload? " + "(If not, unsaved data will be overwritten)", + ) + self.widget_manager.signal.askyesno.emit(request) + if not request.wait(60): + self._show_error("Timed out while waiting for confirmation.", "confirmation_timeout") + return False, None + if request.result() and not self.save(): + return False, None + + if not path.exists() and not self.create_accounts(path): + return False, None + + result = read_account_file(path) + if not is_accounts_valid(result): + self._show_error("Invalid account file.", "invalid_file") + return False, None + + if self.use_system_keyring: + migrated = self._migrate_plaintext_tokens(result) + if migrated is None: + return False, None + if migrated: + create_or_save_account_file(result, path, create_backup=True) + + self.accounts = result + self._path = path + self._is_modified = False + self.accounts_changed.emit() + + return True, copy.deepcopy(result) if return_value else None + except (AccountException, AccountLoadException) as exc: + self._show_error(f"Unable to load account file: {exc.user_message}", "load_failed") + except Exception as exc: + self._show_error(f"Unable to load account file: {exc}", "load_failed") + return False, None + + def save(self, path: str | Path | None = None, create_backup: bool = True) -> bool: + """ + Save accountData to disk + :param path: + :param create_backup: + :return: + status_code: bool + """ + path = Path(path) if path is not None else self.path + + try: + if self.use_system_keyring and self._contains_plaintext_tokens(self.accounts): + self._show_error( + "Refusing to save account data containing plaintext tokens.", + "plaintext_credentials", + ) + return False + + # Verify accountData that store in memory + if not is_accounts_valid(self.accounts): + self._show_error("Current account store is invalid.", "invalid_store") + return False + + create_or_save_account_file(self.accounts, path, create_backup=create_backup) + + self._path = path + self._is_modified = False + + return True + except (AccountException, AccountSaveException) as exc: + self._show_error(f"Unable to save account file: {exc.user_message}", "save_failed") + except Exception as exc: + self._show_error(f"Unable to save account file: {exc}", "save_failed") + return False + + def reload(self) -> tuple[bool, dict | None]: + """Reload accountData from disk. See self.load() to get information about return value""" + return self.load(path=self.path) + + # Search + @staticmethod + def _display_name(account: Account) -> str: + return account.minecraft_name.strip() or account.username.strip() or "Unnamed Account" + + def list_accounts(self) -> list[AccountSummary]: + """ + List (Return) all accounts (couple of account summaries) + :return: + accounts: list[AccountSummary] + """ + result = [] + for account_id in list_accounts_raw(self.accounts): + account = convert_account_data_to_object(self.accounts, account_id) + result.append( + AccountSummary( + display_name=self._display_name(account), + id=account_id, + username=account.username, + minecraft_uuid=account.minecraft_uuid, + minecraft_name=account.minecraft_name, + avatar=account.avatar, + account_type=account.type, + ) + ) + return result + + def get_account(self, account_id: str) -> AccountDetail | None: + """ + Get target account by id + :param account_id: + :return: + account_detail: AccountDetail | None + """ + if not self.contains(account_id, show_error=True): + return None + + account = convert_account_data_to_object(self.accounts, account_id) + + return AccountDetail( + display_name=self._display_name(account), + id=account_id, + username=account.username, + minecraft_uuid=account.minecraft_uuid, + minecraft_name=account.minecraft_name, + avatar=account.avatar, + account_type=account.type, + access_token=account.access_token, + refresh_token=account.refresh_token, + access_token_expires_at=account.access_token_expires_at, + refresh_token_expires_at=account.refresh_token_expires_at, + user_properties=copy.deepcopy(account.user_properties), + ) + + def contains(self, account_id: str, show_error: bool = False) -> bool: + """ + Check if account exists + :param account_id: + :param show_error: + :return: + """ + result = is_account_exists(self.accounts, account_id) + + if not result and show_error: + self._show_error(f"Account ID {account_id} does not exist.", "not_found") + + return result + + # CRUD + def create_account(self, request: CreateAccountRequest) -> str | None: + """ + :param request: + :return: + new_account_id: str + """ + if not self.is_loaded: + success, _ = self.load() + if not success: + return None + + validation = self.validate(request) + if not validation.is_valid: + self._show_error("\n".join(validation.errors.values()), "validation_failed") + return None + if self.contains(request.account_id): + self._show_error(f"Account ID {request.account_id} already exists.", "already_exists") + return None + + previous_tokens = None + credentials_saved = False + try: + updated = copy.deepcopy(self.accounts) + + # Use system keyring + if self.use_system_keyring: + previous_tokens = self._credential_snapshot(request.account_id) + if not self.save_token(request.account_id, ACCESS_TOKEN_KEYWORD, request.access_token): + return None + + if not self.save_token(request.account_id, REFRESH_TOKEN_KEYWORD, request.refresh_token): + self._restore_credentials(request.account_id, previous_tokens) + return None + credentials_saved = True + + account = Account( + account_id=request.account_id, + username=request.username, + access_token=SYSTEM_KEYRING_KEYWORD if self.use_system_keyring else request.access_token, + refresh_token=SYSTEM_KEYRING_KEYWORD if self.use_system_keyring else request.refresh_token, + access_token_expires_at=request.access_token_expires_at, + refresh_token_expires_at=request.refresh_token_expires_at, # Microsoft don't provide refresh token + # expire time. + minecraft_uuid=request.minecraft_uuid, + minecraft_name=request.minecraft_name, + avatar=request.avatar, + type=request.account_type, + user_properties=copy.deepcopy(request.user_properties), + ) + + # Check before actually update + _, account_id = create_account_raw(account, updated) + + if not is_accounts_valid(updated): + raise AccountException("Created account store is invalid.") + + self.accounts = updated + self._is_modified = True + self.account_created.emit(account_id) + self.accounts_changed.emit() + return account_id + except (AccountException, TypeError, ValueError) as exc: + if credentials_saved and previous_tokens is not None: + self._restore_credentials(request.account_id, previous_tokens) + self._show_error(f"Unable to create account: {exc}", "create_failed") + return None + + def update_account(self, account_id: str, changes: UpdateAccountRequest) -> bool: + """ + :param account_id: + :param changes: + :return: + is_updated: bool + """ + # Check if the account exist + if not self.contains(account_id, show_error=True): + return False + + validation = self.validate_update(changes) + if not validation.is_valid: + self._show_error("\n".join(validation.errors.values()), "validation_failed") + return False + + previous_tokens = None + credentials_changed = False + try: + updated = copy.deepcopy(self.accounts) + + # Get target account raw data + raw = copy.deepcopy(list_accounts_raw(updated)[account_id]) + + field_map = { + "username": "username", + "access_token": "accessToken", + "refresh_token": "refreshToken", + "access_token_expires_at": "accessTokenExpiresAt", + "refresh_token_expires_at": "refreshTokenExpiresAt", + "avatar": "avatar", + "account_type": "type", + } + + # Replace value inside the account raw data to new value (from UpdateAccountRequest) + # "None" mean no change + for attribute, key in field_map.items(): + value = getattr(changes, attribute) + if value is not None: + if key in ("accessToken", "refreshToken") and self.use_system_keyring: + if previous_tokens is None: + previous_tokens = self._credential_snapshot(account_id) + token_type = ( + ACCESS_TOKEN_KEYWORD + if key == "accessToken" + else REFRESH_TOKEN_KEYWORD + ) + if not self.save_token(account_id, token_type, value): + self._restore_credentials(account_id, previous_tokens) + return False + credentials_changed = True + value = SYSTEM_KEYRING_KEYWORD + if isinstance(value, datetime.datetime): + value = value.isoformat().replace("+00:00", "Z") + raw[key] = value + + minecraft_profile = raw.setdefault("minecraftProfile", {}) + # Write new minecraft profile data to account data + if changes.minecraft_uuid is not None: + minecraft_profile["uuid"] = changes.minecraft_uuid + if changes.minecraft_name is not None: + minecraft_profile["name"] = changes.minecraft_name + if changes.user_properties is not _UNSET: + raw["userProperties"] = copy.deepcopy(changes.user_properties) + + # Don't update if new raw data is same as current + if raw == list_accounts_raw(self.accounts)[account_id]: + return True + + update_account_raw(updated, account_id, raw) + self.accounts = updated + self._is_modified = True + self.account_updated.emit(account_id) + self.accounts_changed.emit() + return True + except (AccountException, TypeError, ValueError) as exc: + if credentials_changed and previous_tokens is not None: + self._restore_credentials(account_id, previous_tokens) + self._show_error(f"Unable to update account: {exc}", "update_failed") + return False + + def remove_account(self, account_id: str, recreate=False) -> bool: + """ + Remove account by id + :param account_id: + :param recreate: Enable this flag will skip keyring delete process + :return: + is_removed: bool + """ + if not self.contains(account_id, show_error=True): + return False + try: + updated = copy.deepcopy(self.accounts) + previous_current = get_current_account_id_raw(updated) + + remove_account_raw(updated, account_id) + + current = get_current_account_id_raw(updated) + self.accounts = updated + self._is_modified = True + + if not recreate: + # Credential cleanup failure must not resurrect a deleted account. + self.delete_token(account_id, ACCESS_TOKEN_KEYWORD, missing_ok=True) + self.delete_token(account_id, REFRESH_TOKEN_KEYWORD, missing_ok=True) + self.account_removed.emit(account_id) + + # Trigger current_account_changed signal if current account is changed + if current != previous_current: + self.current_account_changed.emit(current) + self.accounts_changed.emit() + return True + except AccountException as exc: + self._show_error(f"Unable to remove account: {exc.user_message}", "remove_failed") + return False + + # Account switch + def set_current_account(self, account_id: str | None) -> bool: + """ + Set current (active) account by id. + :param account_id: Target account's id that you want to set to current (active) account. Set to None + to reset to default. + :return: + is_set: bool + """ + if not self.is_loaded: + success, _ = self.load() + if not success: + return False + + if account_id is not None and not self.contains(account_id, show_error=True): + return False + try: + if account_id == get_current_account_id_raw(self.accounts): + return True + + set_current_account_id_raw(self.accounts, account_id) + + self._is_modified = True + self.current_account_changed.emit(account_id) + self.accounts_changed.emit() + return True + except AccountException as exc: + self._show_error(f"Unable to set current account: {exc.user_message}", "switch_failed") + return False + + def get_current_account_id(self) -> str | None: + if not self.is_loaded: + success, _ = self.load() + if not success: + return None + + return get_current_account_id_raw(self.accounts) + + def get_current_account(self) -> AccountDetail | None: + account_id = self.get_current_account_id() + return self.get_account(account_id) if account_id is not None else None + + def get_launch_account(self, account_id: str | None = None) -> LaunchAccount | None: + """ + Get launch used account. + :param account_id: + :return: + launch_account: LaunchAccount or None (will return current {active} account as the launch account) + """ + account_id = account_id if account_id is not None else self.get_current_account_id() + + if account_id is None: + return None + + account = self.get_account(account_id) + if account is None: + return None + + if account.access_token == SYSTEM_KEYRING_KEYWORD and self.use_system_keyring: + access_token = self.get_token( + account.id, + ACCESS_TOKEN_KEYWORD, + ) + elif account.access_token == SYSTEM_KEYRING_KEYWORD: + self.widget_manager.signal.show_warning_message.emit( + f"Found account profile {account.display_name} use system keyring to store token." + f" But settings did not allow to access system keyring. Please enable it or re-login" + f" account and try again.", + ) + return None + else: + access_token = account.access_token + + if not access_token: + self._show_error( + f"Credentials for {account.display_name} are missing. Please sign in again.", + "credentials_missing", + ) + return None + + return LaunchAccount( + account_id=account.id, + player_name=account.minecraft_name or account.username, + minecraft_uuid=account.minecraft_uuid, + access_token=access_token, + user_type=account.account_type, + user_properties=copy.deepcopy(account.user_properties), + ) + + # Validation + @staticmethod + def _validate_datetime(value, key: str, errors: dict[str, str]) -> None: + if not isinstance(value, (str, datetime.datetime)) or isinstance(value, str) and not value.strip(): + errors[key] = f"{key.replace('_', ' ').title()} must be a non-empty string or datetime." + + @classmethod + def validate(cls, request: CreateAccountRequest) -> ValidationResult: + """ + Verify create account request. + :param request: + :return: + validation_result: ValidationResult + """ + errors = {} + required_strings = { + "account_id": request.account_id, + "username": request.username, + "access_token": request.access_token, + "refresh_token": request.refresh_token, + "account_type": request.account_type, + } + + # Verify in root level + for key, value in required_strings.items(): + if not isinstance(value, str) or not value.strip(): + errors[key] = f"{key.replace('_', ' ').title()} cannot be empty." + + # Verify minecraft profile + for key in ("minecraft_uuid", "minecraft_name", "avatar"): + if not isinstance(getattr(request, key), str): + errors[key] = f"{key.replace('_', ' ').title()} must be a string." + + # userProperties: who use this? + if not isinstance(request.user_properties, list): + errors["user_properties"] = "User properties must be a list." + + cls._validate_datetime(request.access_token_expires_at, "access_token_expires_at", errors) + cls._validate_datetime(request.refresh_token_expires_at, "refresh_token_expires_at", errors) + return ValidationResult(errors) + + @classmethod + def validate_update(cls, request: UpdateAccountRequest) -> ValidationResult: + """ + Verify update account request. + :param request: + :return: + validation_result: ValidationResult + """ + errors = {} + required_if_set = ("username", "access_token", "refresh_token", "account_type") + + # root level + for key in required_if_set: + value = getattr(request, key) + if value is not None and (not isinstance(value, str) or not value.strip()): + errors[key] = f"{key.replace('_', ' ').title()} cannot be empty." + + # minecraft profile + for key in ("minecraft_uuid", "minecraft_name", "avatar"): + value = getattr(request, key) + if value is not None and not isinstance(value, str): + errors[key] = f"{key.replace('_', ' ').title()} must be a string." + + # tokens + for key in ("access_token_expires_at", "refresh_token_expires_at"): + value = getattr(request, key) + if value is not None: + cls._validate_datetime(value, key, errors) + + # user properties + if request.user_properties is not _UNSET and not isinstance(request.user_properties, list): + errors["user_properties"] = "User properties must be a list." + + return ValidationResult(errors) + + def delete_all_accounts(self): + if not self.list_accounts(): + return True + + # Ask user before actually delete + answer = QMessageBox.question( + self.app.main_window, + "Delete all accounts", + "Delete every saved account? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if answer != QMessageBox.StandardButton.Yes: + return False + + for account in list(self.list_accounts()): + if not self.remove_account(account.id): + return False + + return self.save() + + # + # Secret Data Store, Get (e.g. refresh token, access token) + # + @staticmethod + def get_token_full_name(account_id: str, token_type: str) -> str: + return f"Account {account_id} Token ({token_type})" + + def save_token(self, account_id: str, token_type: str, token: str) -> bool: + """ + Save account token to system keyring + :param account_id: + :param token_type: + :param token: + :return: + is_saved: bool + """ + if token_type not in (ACCESS_TOKEN_KEYWORD, REFRESH_TOKEN_KEYWORD): + self.app.logger.error("Unsupported account token type: %r", token_type) + return False + if not isinstance(token, str) or not token: + self.app.logger.error("Refusing to save an empty or invalid account token.") + return False + + full_name = self.get_token_full_name(account_id, token_type) + + try: + keyring.set_password( + SERVICE_NAME, + full_name, + token, + ) + return True + except (KeyringError, TypeError, ValueError) as e: + self.app.logger.error( + "Unable to save token for account {}. Error: {}".format(account_id, e) + ) + self.widget_manager.signal.show_error_message.emit( + "Unable to save account token. Access system keyring error: {}".format(e), + ) + + return False + + def get_token(self, account_id: str, token_type: str) -> str | None: + """ + get account token from system keyring + :param account_id: + :param token_type: + :return: + token: str + """ + full_name = self.get_token_full_name(account_id, token_type) + try: + return keyring.get_password( + SERVICE_NAME, + full_name + ) + except KeyringError as e: + self.app.logger.error( + "Unable to get token for account {}. Error: {}".format(account_id, e) + ) + + return None + + def delete_token(self, account_id: str, token_type: str, missing_ok: bool = False) -> bool: + """ + Delete account token from system keyring + :param account_id: + :param token_type: + :return: + """ + full_name = self.get_token_full_name(account_id, token_type) + + try: + keyring.delete_password( + SERVICE_NAME, + full_name + ) + return True + except PasswordDeleteError as e: + if missing_ok: + return True + self.app.logger.error( + "Account token does not exist for account {}: {}".format(account_id, e) + ) + except KeyringError as e: + self.app.logger.error( + "Unable to delete token for account {}. Error: {}".format(account_id, e) + ) + + return False + + def _credential_snapshot(self, account_id: str) -> dict[str, str | None]: + return { + ACCESS_TOKEN_KEYWORD: self.get_token(account_id, ACCESS_TOKEN_KEYWORD), + REFRESH_TOKEN_KEYWORD: self.get_token(account_id, REFRESH_TOKEN_KEYWORD), + } + + def _restore_credentials( + self, + account_id: str, + snapshot: dict[str, str | None], + ) -> None: + for token_type, token in snapshot.items(): + if token: + self.save_token(account_id, token_type, token) + else: + self.delete_token(account_id, token_type, missing_ok=True) + + @staticmethod + def _contains_plaintext_tokens(accounts: dict) -> bool: + return any( + raw.get(key) != SYSTEM_KEYRING_KEYWORD + for raw in list_accounts_raw(accounts).values() + for key in ("accessToken", "refreshToken") + ) + + def _migrate_plaintext_tokens(self, accounts: dict) -> bool | None: + """Move legacy plaintext credentials to keyring. + + Returns True when the account document changed, False when no migration + was needed, and None when migration failed. + """ + changed = False + for account_id, raw in list_accounts_raw(accounts).items(): + pending = { + ACCESS_TOKEN_KEYWORD: raw.get("accessToken"), + REFRESH_TOKEN_KEYWORD: raw.get("refreshToken"), + } + pending = { + token_type: token + for token_type, token in pending.items() + if token != SYSTEM_KEYRING_KEYWORD + } + if not pending: + continue + + snapshot = self._credential_snapshot(account_id) + for token_type, token in pending.items(): + if not self.save_token(account_id, token_type, token): + self._restore_credentials(account_id, snapshot) + self._show_error( + f"Unable to migrate credentials for account {account_id} to the system keyring.", + "credential_migration_failed", + ) + return None + + if ACCESS_TOKEN_KEYWORD in pending: + raw["accessToken"] = SYSTEM_KEYRING_KEYWORD + if REFRESH_TOKEN_KEYWORD in pending: + raw["refreshToken"] = SYSTEM_KEYRING_KEYWORD + changed = True + + return changed + + @property + def path(self) -> Path: + if self._path is None: + self._path = self.default_account_path + return self._path + + @property + def is_loaded(self) -> bool: + return bool(self.accounts) and is_accounts_valid(self.accounts) + + @property + def is_modified(self) -> bool: + return self._is_modified + + # + # Account Login, Refresh, API Handle + # + def start_login(self): + if self.login_thread is not None: + return False + + # Create login worker and run it in outer thread + self.login_thread = QThread(self) + self.login_worker = LoginWorker(CLIENT_ID) + self.login_worker.moveToThread(self.login_thread) + + self.login_worker.device_code_ready.connect( + self.login_signal.device_code_ready.emit + ) + self.login_worker.login_succeeded.connect( + self.login_signal.login_succeeded.emit + ) + self.login_worker.login_failed.connect( + self.login_signal.login_failed.emit + ) + self.login_thread.started.connect(self.login_worker.run) + self.login_worker.finished.connect(self.login_thread.quit) + self.login_worker.finished.connect(self.login_worker.deleteLater) + self.login_thread.finished.connect(self._login_thread_finished) + self.login_thread.start() + return True + + def _login_thread_finished(self): + self.login_cleanup() + self.login_signal.finished.emit() + + def login_cleanup(self): + thread = self.login_thread + self.login_worker = None + self.login_thread = None + + if thread is not None: + thread.deleteLater() + + def login_succeeded(self, account: dict): + now = datetime.datetime.now(datetime.timezone.utc) + account_id = account.get("id", "") + request = CreateAccountRequest( + account_id=account_id, + username=account.get("username") or account.get("name", ""), + access_token=account.get("access_token", ""), + refresh_token=account.get("refresh_token", ""), + access_token_expires_at=account.get("access_token_expires_at") or now + datetime.timedelta(hours=24), + refresh_token_expires_at=account.get("refresh_token_expires_at") or now + datetime.timedelta(days=90), + minecraft_uuid=account_id, # account_id's value is provided account's uuid + minecraft_name=account.get("name", ""), + account_type="msa", + ) + + if self.contains(account_id): + updated = self.update_account( + account_id, + UpdateAccountRequest( + username=request.username, + access_token=request.access_token, + refresh_token=request.refresh_token, + access_token_expires_at=request.access_token_expires_at, + refresh_token_expires_at=request.refresh_token_expires_at, + minecraft_uuid=request.minecraft_uuid, + minecraft_name=request.minecraft_name, + account_type=request.account_type, + ), + ) + if not updated: + self.widget_manager.signal.show_error_message.emit( + "Unable to update the existing account after login." + ) + return False + created_id = account_id + else: + created_id = self.create_account(request) + if created_id is None: + self.widget_manager.signal.show_error_message.emit( + "Unable to finish login because account creation failed." + ) + return False + + if not self.set_current_account(created_id): + self.widget_manager.signal.show_warning_message.emit( + "Set login account as active account failed." + ) + + if not self.save(): + self.widget_manager.signal.show_warning_message.emit( + "Unable to save account changes to account data. New account may did not save." + ) + + return created_id + + def cancel_login(self): + if self.login_worker is not None: + self.login_worker.cancel() + + # + # Third Party + # + @staticmethod + def has_saved_head(existing_avatar: str) -> bool: + if not isinstance(existing_avatar, str) or not existing_avatar.startswith("data:image/"): + return False + try: + return not data_url_to_qt_image(existing_avatar).isNull() + except ValueError: + return False + + def get_head_pixmap(self, existing_avatar: str, size: QSize=QSize(32, 32)) -> QPixmap: + """ + Get head pixmap + + Credits: + mcheads.org: APi provider + + :param existing_avatar: If you have data from accountData. You can call this function with this parameter + :param size: + :return: + image: QPixmap + """ + # Return existing data + if self.has_saved_head(existing_avatar): + try: + image = data_url_to_qt_image(existing_avatar) + return QPixmap.fromImage( + image.scaled( + size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + except ValueError as exc: + self.app.logger.warning( + f"Unable to decode saved account avatar: {exc}" + ) + + # Network access is intentionally not performed here. UI callers get a + # stable fallback immediately while request_account_head() works in the + # background. + return self.app.get_icon(Path(self.app.icon_dir, "head.png")).pixmap(size) + + def request_account_head(self, account_id: str, username: str, size: int = 32) -> bool: + """Download and persist a missing account head without blocking the UI.""" + if account_id in self._head_jobs or not username.strip(): + return False + + account = self.get_account(account_id) + if account is None or self.has_saved_head(account.avatar): + return False + + request_size = min(256, max(32, int(size))) + thread = QThread(self) + worker = HeadWorker(account_id, username, request_size) + worker.moveToThread(thread) + self._head_jobs[account_id] = (thread, worker) + + thread.started.connect(worker.run) + worker.succeeded.connect(self._account_head_downloaded) + worker.failed.connect(self._account_head_failed) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect( + lambda target_id=account_id: self._account_head_job_finished(target_id) + ) + thread.start() + return True + + def _account_head_downloaded(self, account_id: str, image_raw: bytes) -> None: + if not self.contains(account_id): + return + + try: + updated = copy.deepcopy(self.accounts) + save_account_avatar(updated, account_id, image_raw) + self.accounts = updated + self._is_modified = True + if not self.save(): + raise AccountSaveException("Unable to persist downloaded account head.") + self.head_signal.updated.emit(account_id) + except (AccountException, AccountSaveException, TypeError, ValueError) as exc: + self._account_head_failed(account_id, str(exc)) + + def _account_head_failed(self, account_id: str, message: str) -> None: + self.app.logger.warning( + "Unable to get account head for %s: %s", account_id, message + ) + self.head_signal.failed.emit(account_id, message) + + def _account_head_job_finished(self, account_id: str) -> None: + job = self._head_jobs.pop(account_id, None) + if job is not None: + job[0].deleteLater() + + +class HeadWorker(QObject): + """Download and validate account head bytes outside the UI thread.""" + + succeeded = Signal(str, bytes) + failed = Signal(str, str) + finished = Signal() + + def __init__(self, account_id: str, username: str, size: int): + super().__init__() + self.account_id = account_id + self.username = username + self.size = size + + def run(self): + try: + image_raw = get_minecraft_head(self.username, size=self.size) + if QImage.fromData(image_raw).isNull(): + raise RuntimeError("Downloaded account head is not a supported image.") + self.succeeded.emit(self.account_id, image_raw) + except ThirdPartyAPIException as exc: + self.failed.emit(self.account_id, exc.user_message) + except Exception as exc: + self.failed.emit(self.account_id, str(exc)) + finally: + self.finished.emit() + + +class LoginWorker(QObject): + """ + Run login process outside the main thread + """ + device_code_ready = Signal(dict) + login_succeeded = Signal(dict) + login_failed = Signal(str) + finished = Signal() + + def __init__(self, client_id: str): + super().__init__() + self.client_id = client_id + self._cancelled = False + + def cancel(self): + self._cancelled = True + + def run(self): + try: + # Get device code + device_code, device = get_device_code(self.client_id) + self.device_code_ready.emit(device) + + microsoft_access_token, refresh_token, microsoft = get_microsoft_token( + self.client_id, + device_code, + interval=device.get("interval", 5), + expires_in=device.get("expires_in", 900), + cancelled=lambda: self._cancelled, + ) + xbox_token, user_hash, _ = get_xbox_token_and_user_hash(microsoft_access_token) + xsts_token, _ = get_xsts_token(xbox_token) + minecraft_access_token, minecraft = get_minecraft_access_token(user_hash, xsts_token) + entitlements = get_account_entitlements(minecraft_access_token) + profile = get_minecraft_profile(minecraft_access_token) + + # convert expire value + now = datetime.datetime.now(datetime.timezone.utc) + try: + access_expires_in = max(0, int(minecraft.get("expires_in", 86400))) + except (TypeError, ValueError): + access_expires_in = 86400 + + self.login_succeeded.emit({ + "id": profile["id"], # UUID + "name": profile["name"], + "username": profile["name"], + "access_token": minecraft_access_token, + "refresh_token": refresh_token, + "access_token_expires_at": now + datetime.timedelta(seconds=access_expires_in), + # Microsoft does not provide a refresh-token expiry timestamp. + "refresh_token_expires_at": now + datetime.timedelta(days=90), + "has_license": has_minecraft_license(entitlements), + }) + except InterruptedError: + pass + except LauncherException as exc: + self.login_failed.emit(exc.user_message) + except (KeyError, TypeError, ValueError, TimeoutError) as exc: + self.login_failed.emit(str(exc)) + except Exception as exc: + self.login_failed.emit(f"Unexpected authentication error: {exc}") + finally: + self.finished.emit() diff --git a/manager/game.py b/manager/game.py index 8629f53..64a006f 100644 --- a/manager/game.py +++ b/manager/game.py @@ -8,7 +8,9 @@ from dataclasses import dataclass, field from pathlib import Path from uuid import uuid4 from PySide6.QtCore import QObject +from PySide6.QtWidgets import QInputDialog +from constant import CLIENT_ID, LAUNCHER_VERSION from core_lib.common.common import FileObject from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \ NoSpecifiedVersionKeyException @@ -20,6 +22,7 @@ from core_lib.game.version_info import fetch_and_save_version_manifest, get_spec save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \ LATEST_VERSION_ALIASES from core_lib.runtime.java import find_java_installations +from manager.account import AccountManager, LaunchAccount from manager.profile import LaunchProfile from manager.widgets import SelectPathRequest, AskForRequest @@ -411,11 +414,12 @@ class LaunchResult: self.warnings.append(message) class LaunchManager: - def __init__(self, app, manifest_manager: ManifestManager, java_manager: JavaManager): + def __init__(self, app, manifest_manager: ManifestManager, java_manager: JavaManager, account_manager: AccountManager): self.app = app self.manifest_mgr = manifest_manager self.widget_mgr = app.widget_manager self.java_mgr = java_manager + self.account_mgr = account_manager self.launched_profiles: list[str] = [] def launch(self, profile: LaunchProfile) -> LaunchResult: @@ -463,6 +467,15 @@ class LaunchManager: if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir): download_full_libraries(version_manifest, libraries_dir, natives_dir) + current_account_id = self.account_mgr.get_current_account_id() + + if not current_account_id: + return result.fail( + "Select a account before launching." + ) + + launch_account: LaunchAccount = self.account_mgr.get_launch_account() + # Asset default_assets_dir = Path(self.app.work_dir, "assets") if not check_assets_exist(version_manifest, default_assets_dir, launcher_root=self.app.work_dir): @@ -498,19 +511,20 @@ class LaunchManager: # Mappings arg_maps = ArgumentMappings( - player_name="Player", + player_name=launch_account.player_name, version_name=version_id, game_directory=game_dir.as_posix(), assets_root=assets_dir.as_posix(), legacy_assets_root=assets_dir, assets_index_name=asset_id, - auth_uuid="00000000000000000000000000000000", - auth_access_token="0", - user_type="legacy", + auth_uuid=launch_account.minecraft_uuid, + auth_access_token=launch_account.access_token, + clientid=CLIENT_ID, + user_type=launch_account.user_type, version_type=profile.version_type, natives_directory=natives_dir.as_posix(), launcher_name="TestLauncher", - launcher_version="0.1", + launcher_version=LAUNCHER_VERSION, classpath=classpath, classpath_separator=os.pathsep, library_directory=libraries_dir.as_posix(), @@ -553,15 +567,18 @@ class LaunchManager: self.app.logger.debug("Use system environment java.") java_path = "java" + self.app.logger.debug(f"Java executable path is: {java_path}") + # Finally, generate launch command cmd = generate_launch_command( arg_maps=arg_maps, main_class=main_class, java_path=java_path, - full_args=target_args, + full_args=target_args ) - self.app.logger.debug(f"Launch command: {cmd}") + safe_cmd = ["" if value == launch_account.access_token else value for value in cmd] + self.app.logger.debug(f"Launch command: {safe_cmd}") # run it process = subprocess.Popen( @@ -588,11 +605,22 @@ class LaunchManager: def is_profile_running(self, profile_id): return profile_id in self.launched_profiles + def profile_finished(self, profile_id: str, exit_code: int) -> None: + try: + self.launched_profiles.remove(profile_id) + except ValueError: + pass + + self.app.logger.debug( + f"Profile {profile_id} exited with code {exit_code}." + ) + class GameManager(QObject): def __init__(self, app, parent=None): super(GameManager, self).__init__(parent) self.app = app + self.account_manager = app.account_manager self.widget_mgr = app.widget_manager self.manifest = ManifestManager(self.app) self.java = JavaManager(self.app) - self.launch = LaunchManager(self.app, self.manifest, self.java) + self.launch = LaunchManager(self.app, self.manifest, self.java, self.account_manager) diff --git a/pages/account.py b/pages/account.py index 536a30c..664e7d3 100644 --- a/pages/account.py +++ b/pages/account.py @@ -1,31 +1,52 @@ +from __future__ import annotations + +import webbrowser from pathlib import Path -from PySide6.QtCore import Qt, QSize -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, QListWidgetItem, \ - QMenu +from PySide6.QtCore import QSize, Qt, Signal, QTimer +from PySide6.QtGui import QPixmap, QFontDatabase +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMenu, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, QDialog, QLineEdit, QApplication, +) + +from constant import CLIENT_ID, MICROSOFT_VERIFY_ENDPOINT + +from core_lib.qt.hack import move_window_to_center +from manager.account import AccountManager, AccountSummary class AccountWidget(QWidget): - def __init__(self, account_name, account_head: QPixmap, login_status, parent=None, - /, delete_account_callback=None, check_account_callback=None, switch_account_callback=None): - QWidget.__init__(self, parent) + def __init__(self, account_name: str, account_head: QPixmap, login_status: str, parent=None, + /, delete_account_callback=None, check_account_callback=None, switch_account_callback=None,): + super().__init__(parent) + # Layout self.layout = QHBoxLayout(self) + # Widgets self.head_icon_label = QLabel() self.head_icon_label.setObjectName("HeadIcon") + self.head_icon_label.setFixedSize(account_head.size()) self.head_icon_label.setPixmap(account_head) self.account_name = QLabel(account_name) - self.status_label = QLabel(login_status) + # Bind widgets self.layout.addWidget(self.head_icon_label) self.layout.addWidget(self.account_name) self.layout.addStretch() self.layout.addWidget(self.status_label) + # Callback self.delete_account_callback = delete_account_callback self.check_account_callback = check_account_callback self.switch_account_callback = switch_account_callback @@ -34,67 +55,294 @@ class AccountWidget(QWidget): self.customContextMenuRequested.connect(self.show_right_click_menu) def show_right_click_menu(self, position): - context_menu = QMenu(self) + menu = QMenu(self) + switch_action = menu.addAction("Switch to this account") + check_action = menu.addAction("Check login status") + menu.addSeparator() + delete_action = menu.addAction("Delete account") + selected = menu.exec(self.mapToGlobal(position)) - switch_acc_action = context_menu.addAction("Switch to this account") - check_log_stat_action = context_menu.addAction("Check login status") + if selected == delete_action and callable(self.delete_account_callback): + self.delete_account_callback() + elif selected == check_action and callable(self.check_account_callback): + self.check_account_callback() + elif selected == switch_action and callable(self.switch_account_callback): + self.switch_account_callback() - context_menu.addSeparator() +class LoginDialog(QDialog): + def __init__(self, app, parent=None, code="", error_message=""): + super().__init__(parent) + self.app = app + self.setWindowTitle("Microsoft Login") + self.setMinimumWidth(440) + self.setMinimumHeight(380) + self.resize(440, 420) + move_window_to_center(self) - delete_account_action = context_menu.addAction("Delete account") + # Layout + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(24, 22, 24, 22) + self.layout.setSpacing(14) - global_position = self.mapToGlobal(position) + # Top widgets + self.title_label = QLabel("Login Minecraft Account") + self.title_label.setObjectName("dialogTitle") + self.help_label = QLabel("Copy the code below, then continue in your browser.") + self.help_label.setObjectName("helpText") + self.help_label.setWordWrap(True) - selected_action = context_menu.exec(global_position) + # Center + self.code_edit = QLineEdit() + self.code_edit.setObjectName("codeEdit") + self.code_edit.setReadOnly(True) + self.code_edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.code_edit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.code_edit.setText(code) + self.code_label = self.code_edit + self.error_label = QLabel() + self.error_label.setObjectName("errorMessage") + self.error_label.setWordWrap(True) + self.error_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - if selected_action == delete_account_action: - # handle callback here - if callable(self.delete_account_callback): self.delete_account_callback() - elif selected_action == check_log_stat_action and callable(self.check_account_callback): self.check_account_callback() - elif selected_action == switch_acc_action and callable(self.switch_account_callback): self.switch_account_callback() + # Button + self.copy_code_button = QPushButton("Copy code") + self.copy_code_button.setObjectName("copyCodeButton") + self.open_browser_button = QPushButton("Open browser") + self.open_browser_button.setObjectName("openBrowserButton") + self.center_layout = QVBoxLayout() + self.center_layout.setSpacing(10) + self.center_layout.addWidget(self.code_edit) + self.center_layout.addWidget(self.error_label) + + self.button_layout = QHBoxLayout() + self.button_layout.setSpacing(8) + self.button_layout.addWidget(self.copy_code_button) + self.button_layout.addWidget(self.open_browser_button) + + self.layout.addWidget(self.title_label) + self.layout.addWidget(self.help_label) + self.layout.addStretch() + self.layout.addLayout(self.center_layout) + self.layout.addStretch() + self.layout.addLayout(self.button_layout) + self.set_error_message(error_message) + + self.copy_code_button.clicked.connect(self.copy_code) + + self.app.apply_qss(Path("login_dialog.qss"), self.setStyleSheet) + + @property + def code(self): + return self.code_edit.text() + + def set_code(self, code): + self.code_edit.setText(str(code or "")) + + def copy_code(self): + QApplication.clipboard().setText(self.code) + self.copy_code_button.setText("Copied!") + self.copy_code_button.setEnabled(False) + QTimer.singleShot(1200, self._reset_copy_button) + + def _reset_copy_button(self): + self.copy_code_button.setText("Copy code") + self.copy_code_button.setEnabled(True) + + def set_error_message(self, message): + message = str(message or "").strip() + self.error_label.setText(message) + self.error_label.setVisible(bool(message)) class AccountPage(QWidget): + account_authenticated = Signal(dict) + def __init__(self, app, parent=None): - QWidget.__init__(self, parent) + super().__init__(parent) self.app = app + self.widget_manager = app.widget_manager + self.account_manager: AccountManager = app.account_manager + self.client_id = CLIENT_ID - self.layout = QHBoxLayout() + self.login_dialog = None - self.main_layout = QVBoxLayout() - self.right_layout = QVBoxLayout() + # Layout + main_layout = QVBoxLayout() + right_layout = QVBoxLayout() + layout = QHBoxLayout(self) + # Widgets self.account_list = QListWidget(self) - self.add_account_button = QPushButton("Add Account") self.refresh_button = QPushButton("Refresh all") self.delete_all_button = QPushButton("Delete All") - self.main_layout.addWidget(self.account_list) - self.right_layout.addWidget(self.add_account_button) - self.right_layout.addWidget(self.refresh_button) - self.right_layout.addWidget(self.delete_all_button) - self.right_layout.addStretch() + # Button click handle + self.add_account_button.clicked.connect(self.start_login_page) + self.refresh_button.clicked.connect(self.refresh_accounts) + self.delete_all_button.clicked.connect(self.account_manager.delete_all_accounts) - self.layout.addLayout(self.main_layout) - self.layout.addLayout(self.right_layout) + # Bind widget + main_layout.addWidget(self.account_list) + right_layout.addWidget(self.add_account_button) + right_layout.addWidget(self.refresh_button) + right_layout.addWidget(self.delete_all_button) + right_layout.addStretch() - self.setLayout(self.layout) + layout.addLayout(main_layout, 1) + layout.addLayout(right_layout) - self.test() + self.account_manager.accounts_changed.connect(self._render_accounts) + self.account_manager.current_account_changed.connect(self._current_account_changed) + self.account_manager.login_signal.device_code_ready.connect(self._show_device_code) + self.account_manager.login_signal.login_succeeded.connect(self._login_succeeded) + self.account_manager.login_signal.login_failed.connect(self._login_failed) + self.account_manager.login_signal.finished.connect(self._login_finished) + self.account_manager.head_signal.updated.connect(self._account_head_updated) + self._render_accounts() - def test(self): - head_icon = self.app.get_icon(Path(self.app.icon_dir, "head.png")) - for i in range(1, 11): - name = f"Player_{i}" - head_pixmap = head_icon.pixmap(QSize(50, 50)) - list_item = QListWidgetItem() + def _render_accounts(self): + self.account_list.clear() - def delete(): - self.account_list.removeItemWidget(list_item) - item = AccountWidget(name, head_pixmap, self, delete_account_callback=delete) - list_item.setSizeHint(item.sizeHint()) - self.account_list.addItem(list_item) - self.account_list.setItemWidget(list_item, item) + current_id = self.account_manager.get_current_account_id() + accounts = self.account_manager.list_accounts() + # Add suggestion to account list if no account available + if not accounts: + empty_item = QListWidgetItem("No accounts yet. Use Add Account to sign in.") + empty_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + empty_item.setFlags(Qt.ItemFlag.NoItemFlags) + self.account_list.addItem(empty_item) + for account in accounts: + self._add_account_item(account, account.id == current_id) + + def _add_account_item(self, account: AccountSummary, is_current: bool): + list_item = QListWidgetItem() + list_item.setData(Qt.ItemDataRole.UserRole, account.id) + + head_size = QSize(32, 32) + widget = AccountWidget( + account.display_name, + self.account_manager.get_head_pixmap(account.avatar, head_size), + "Current" if is_current else "Available", + self, + delete_account_callback=lambda: self._delete_account(account.id), + check_account_callback=lambda: self._show_account_status(account.id), + switch_account_callback=lambda: self._switch_account(account.id), + ) + + list_item.setSizeHint(widget.sizeHint()) + self.account_list.addItem(list_item) + self.account_list.setItemWidget(list_item, widget) + + if not self.account_manager.has_saved_head(account.avatar): + self.account_manager.request_account_head( + account.id, + account.minecraft_name or account.username, + max(head_size.width(), head_size.height()), + ) + + def _account_head_updated(self, account_id: str): + account = self.account_manager.get_account(account_id) + if account is None: + return + + for row in range(self.account_list.count()): + item = self.account_list.item(row) + if item.data(Qt.ItemDataRole.UserRole) != account_id: + continue + widget = self.account_list.itemWidget(item) + if isinstance(widget, AccountWidget): + widget.head_icon_label.setPixmap( + self.account_manager.get_head_pixmap(account.avatar, QSize(32, 32)) + ) + break + + def _delete_account(self, account_id: str): + if self.account_manager.remove_account(account_id): + self.account_manager.save() + + def _show_account_status(self, account_id: str): + account = self.account_manager.get_account(account_id) + + if account is None: + return + + QMessageBox.information( + self, + "Account status", + f"{account.display_name} — Minecraft profile " + f"{'available' if account.minecraft_uuid else 'not available'}", + ) + + def _switch_account(self, account_id: str): + self.account_manager.set_current_account(account_id) + self.account_manager.save() + + def _current_account_changed(self, _account_id): + self._render_accounts() + + def refresh_accounts(self): + self.account_manager.reload() + + # Microsoft sign-in + def start_login_page(self): + # Active existing login dialog + if self.login_dialog is not None: + self.login_dialog.raise_() + self.login_dialog.activateWindow() + return + + # Create login dialog + self.add_account_button.setEnabled(False) + self.login_dialog = LoginDialog(parent=self, app=self.app) + self.login_dialog.help_label.setText("Requesting a Microsoft sign-in code…") + self.login_dialog.copy_code_button.setEnabled(False) + self.login_dialog.open_browser_button.setEnabled(False) + self.login_dialog.open_browser_button.clicked.connect(self.open_sign_in_page) + self.login_dialog.rejected.connect(self.account_manager.cancel_login) + self.login_dialog.show() + + self.account_manager.start_login() + + def _show_device_code(self, device: dict): + self._verification_uri = device.get("verification_uri", MICROSOFT_VERIFY_ENDPOINT) + + if self.login_dialog is not None: + self.login_dialog.help_label.setText( + device.get("message") or "Copy the code below, then continue in your browser." + ) + self.login_dialog.set_code(device.get("user_code", "")) + self.login_dialog.copy_code_button.setEnabled(True) + self.login_dialog.open_browser_button.setEnabled(True) + + self.open_sign_in_page() + + def open_sign_in_page(self): + webbrowser.open(self._verification_uri) + + def _login_succeeded(self, account: dict): + created_id = self.account_manager.login_succeeded(account) + + # Cleanup + if self.login_dialog is not None: + dialog = self.login_dialog + self.login_dialog = None + dialog.accept() + dialog.deleteLater() + + if created_id is not None: + self.account_authenticated.emit({"account_id": created_id}) + + def _login_failed(self, message: str): + if self.login_dialog is not None: + self.login_dialog.set_error_message(message) + self.login_dialog.help_label.setText("Microsoft sign-in could not be completed.") + self.login_dialog.copy_code_button.setEnabled(False) + self.login_dialog.open_browser_button.setEnabled(False) + else: + QMessageBox.critical(self, "Microsoft sign-in failed", message) + + def _login_finished(self): + self.add_account_button.setEnabled(True) diff --git a/pages/launch_profile.py b/pages/launch_profile.py index 778b396..1e8409a 100644 --- a/pages/launch_profile.py +++ b/pages/launch_profile.py @@ -197,7 +197,7 @@ class LaunchProfilePage(QWidget): self.bottom_layout = QHBoxLayout() # Widgets - self.status_label = QLabel("Select a version to launch") + self.status_label = QLabel("Select a profile to launch") self.status_label.setStyleSheet("font-size: 12px; font-weight: 600;") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -363,50 +363,54 @@ class LaunchProfilePage(QWidget): self.profile_button.set_popup_open(False) def launch_profile(self) -> None: - profile_id = self.profile_button.property("profile_id") - self.status_label.setText("Preparing to launch...") - launch_profile = self.app.profile_manager.get_launch_profile(profile_id) + try: + profile_id = self.profile_button.property("profile_id") + self.status_label.setText("Preparing to launch...") + launch_profile = self.app.profile_manager.get_launch_profile(profile_id) - if launch_profile is None: - self.status_label.setText("Unable to load the selected profile.") - return + if launch_profile is None: + self.status_label.setText("Unable to load the selected profile.") + return - if self.launch_manager.is_profile_running(launch_profile.profile_id): - request = AskForRequest( - "Launch Request", - "Target profile {} is running. Are you sure you want to launch it again?".format( - launch_profile.display_name - ), - ) - self.widget_mgr.signal.askyesno.emit(request) + if self.launch_manager.is_profile_running(launch_profile.profile_id): + request = AskForRequest( + "Launch Request", + "Target profile {} is running. Are you sure you want to launch it again?".format( + launch_profile.display_name + ), + ) + self.widget_mgr.signal.askyesno.emit(request) - if not request.wait(60): + if not request.wait(60): + self.widget_mgr.signal.show_error_message.emit( + "Time out waiting for request for launch." + ) + return + + if not request.result() is True: + return + + result = self.launch_manager.launch(launch_profile) + + if result.success: + self.status_label.setText("Launched!") + log_window = ProcessLogWindow( + result.process, + launch_profile.profile_id, + title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})", + parent=None, + finished_callback=self.launch_manager.profile_finished, + ) + for warning in result.warnings: + log_window.append_message(f"[launcher warning] {warning}") + self.process_log_windows.append(log_window) + log_window.show() + else: self.widget_mgr.signal.show_error_message.emit( - "Time out waiting for request for launch." + "Unable to launch profile {}: {}".format( + launch_profile.display_name, + result.error_message, + ) ) - return - - if not request.result() is True: - return - - result = self.launch_manager.launch(launch_profile) - - if result.success: - self.status_label.setText("Launched!") - log_window = ProcessLogWindow( - result.process, - launch_profile.profile_id, - title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})", - parent=None, - ) - for warning in result.warnings: - log_window.append_message(f"[launcher warning] {warning}") - self.process_log_windows.append(log_window) - log_window.show() - else: - self.widget_mgr.signal.show_error_message.emit( - "Unable to launch profile {}: {}".format( - launch_profile.display_name, - result.error_message, - ) - ) + finally: + self.status_label.setText("Select a profile to launch") diff --git a/pages/settings.py b/pages/settings.py index e69de29..c907ceb 100644 --- a/pages/settings.py +++ b/pages/settings.py @@ -0,0 +1,296 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QPalette, QFont +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QSlider, + QStackedWidget, + QVBoxLayout, + QWidget, QLineEdit, +) + + +class SettingsItem(QPushButton): + """ + A modern look settings button. + """ + clicked_page = Signal() + + def __init__(self, title: str, description: str = ""): + super().__init__() + + self.setCursor(Qt.CursorShape.PointingHandCursor) + # Widget size + self.setMinimumHeight(68) + self.setMaximumWidth(800) + + # Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 10, 16, 10) + layout.setSpacing(3) + + # Widgets + self.title_label = QLabel(title) + self.title_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + + self.description_label = QLabel(description) + self.description_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents + ) + + layout.addWidget(self.title_label) + + # Show description if existing + if description: + layout.addWidget(self.description_label) + + self.update_palette() + + def update_palette(self): + """ + Apply system palette + :return: + """ + palette = self.palette() + + button_text = palette.color(QPalette.ColorRole.ButtonText) + placeholder_text = palette.color(QPalette.ColorRole.PlaceholderText) + + title_palette = self.title_label.palette() + title_palette.setColor( + QPalette.ColorRole.WindowText, + button_text, + ) + self.title_label.setPalette(title_palette) + + description_palette = self.description_label.palette() + description_palette.setColor( + QPalette.ColorRole.WindowText, + placeholder_text, + ) + self.description_label.setPalette(description_palette) + + title_font = self.title_label.font() + title_font.setPointSize(11) + title_font.setWeight(QFont.Weight.Bold) + self.title_label.setFont(title_font) + + description_font = self.description_label.font() + description_font.setPointSize(9) + self.description_label.setFont(description_font) + + +class SettingsSubPage(QWidget): + def __init__(self, title: str, back_callback): + super().__init__() + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 16, 20, 20) + main_layout.setSpacing(16) + + header_layout = QHBoxLayout() + + self.back_button = QPushButton("←") + self.back_button.setFixedSize(36, 36) + self.back_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.back_button.clicked.connect(back_callback) + + self.title_label = QLabel(title) + + title_font = self.title_label.font() + title_font.setPointSize(15) + title_font.setBold(True) + self.title_label.setFont(title_font) + + header_layout.addWidget(self.back_button) + header_layout.addWidget(self.title_label) + header_layout.addStretch() + + self.content_layout = QVBoxLayout() + self.content_layout.setSpacing(12) + + main_layout.addLayout(header_layout) + main_layout.addLayout(self.content_layout) + main_layout.addStretch() + + self.update_palette() + + def update_palette(self): + # Apply system palette + palette = self.palette() + + button_color = palette.color(QPalette.ColorRole.Button) + border_color = palette.color(QPalette.ColorRole.Mid) + + self.back_button.setStyleSheet(""" + QPushButton { + border: none; + border-radius: 8px; + background-color: transparent; + font-size: 22px; + } + + QPushButton:hover { + background-color: palette(button); + } + + QPushButton:pressed { + background-color: palette(midlight); + } + """) + + def changeEvent(self, event): + # Update when system palette change + if event.type() in ( + event.Type.PaletteChange, + event.Type.ApplicationPaletteChange, + ): + self.update_palette() + + super().changeEvent(event) + + +class SettingsPage(QWidget): + def __init__(self, app, parent=None): + super().__init__(parent) + self.app = app + + self.setWindowTitle("Settings") + self.resize(560, 440) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + + self.stack = QStackedWidget() + + self.main_page = self.create_main_page() + self.general_page = self.create_general_page() + self.appearance_page = self.create_appearance_page() + self.profile_page = self.create_profile_page() + + self.stack.addWidget(self.main_page) + self.stack.addWidget(self.general_page) + self.stack.addWidget(self.appearance_page) + self.stack.addWidget(self.profile_page) + + root_layout.addWidget(self.stack) + + def create_main_page(self) -> QWidget: + page = QWidget() + + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 24) + layout.setSpacing(12) + + title = QLabel("Settings") + + title_font = title.font() + title_font.setPointSize(18) + title_font.setBold(True) + title.setFont(title_font) + + # Setting options + info_button = SettingsItem( + "Wait!", + "Setting page is not full implemented yet.", + ) + + general_button = SettingsItem( + "General", + "Some general settings for launcher ", + ) + general_button.clicked.connect( + lambda: self.open_page(self.general_page) + ) + + appearance_button = SettingsItem( + "Appearance", + "Settings to change the launcher look like", + ) + appearance_button.clicked.connect( + lambda: self.open_page(self.appearance_page) + ) + + profile_button = SettingsItem( + "Profile", + "Store profile settings", + ) + profile_button.clicked.connect( + lambda: self.open_page(self.profile_page) + ) + + layout.addWidget(title) + layout.addSpacing(8) + layout.addWidget(info_button) + layout.addWidget(general_button) + layout.addWidget(appearance_button) + layout.addWidget(profile_button) + layout.addStretch() + + return page + + def create_general_page(self) -> QWidget: + page = SettingsSubPage("General", self.go_back) + + language_label = QLabel("Language") + + language_combo = QComboBox() + language_combo.addItems([ + "English", + "Chinese (Traditional)", + ]) + + launcher_dir_label = QLabel("Current launcher working directory") + launcher_dir = QLineEdit(self.app.work_dir.as_posix()) + launcher_dir.setReadOnly(True) + + program_dir_label = QLabel("Current program directory") + program_dir = QLineEdit(self.app.program_dir.as_posix()) + program_dir.setReadOnly(True) + + page.content_layout.addWidget(launcher_dir_label) + page.content_layout.addWidget(launcher_dir) + page.content_layout.addWidget(program_dir_label) + page.content_layout.addWidget(program_dir) + page.content_layout.addSpacing(8) + page.content_layout.addWidget(language_label) + page.content_layout.addWidget(language_combo) + + return page + + def create_appearance_page(self) -> QWidget: + page = SettingsSubPage("Appearance", self.go_back) + + theme_label = QLabel("Theme") + + theme_combo = QComboBox() + theme_combo.addItems([ + "Follow system settings", + "Light (unfinished)", + "Dark", + ]) + + page.content_layout.addWidget(theme_label) + page.content_layout.addWidget(theme_combo) + + return page + + def create_profile_page(self) -> QWidget: + page = SettingsSubPage("Profile", self.go_back) + + test_label = QLabel("Still in development...") + + page.content_layout.addWidget(test_label) + + return page + + def open_page(self, page: QWidget): + self.stack.setCurrentWidget(page) + + def go_back(self): + self.stack.setCurrentWidget(self.main_page) \ No newline at end of file diff --git a/pages/test.py b/pages/test.py index 335b60e..8beba34 100644 --- a/pages/test.py +++ b/pages/test.py @@ -23,7 +23,7 @@ from core_lib.game.version_info import (find_useful_part_in_specific_version_man download_core_file) from core_lib.qt.hack import is_main_thread from core_lib.runtime.java import find_java_installations -from manager.game import ManifestManager +from manager.game import ManifestManager, JavaManager from manager.widgets import SelectPathRequest, AskForRequest MAX_DOWNLOAD_ATTEMPTS = 3 @@ -37,6 +37,7 @@ class TestPage(QWidget): self.parent = parent self.widget_mgr = widget_manager self.manifest_mgr: ManifestManager = app.game_manager.manifest + self.java_mgr: JavaManager = app.game_manager.java self.resize(800, 600) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) @@ -781,7 +782,7 @@ class TestPage(QWidget): return # Use found java runtime (if available) - java_path = self.get_support_runtime_jvm_executable( + java_path = self.java_mgr.get_support_runtime_jvm_executable( java_major_version, no_error=True ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e449dfb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "testlauncher" +version = "0.0.4a0" +description = "A lightweight Minecraft launcher." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "wei", email = "weidnite1222@gmail.com" }, +] +dependencies = [ + "colorlog>=6.9,<7", + "keyring>=25.6,<26", + "PySide6>=6.8,<7", + "requests>=2.32,<3", +] + +[dependency-groups] +build = [ + "Nuitka>=4.1", + "ordered-set>=4.1", + "zstandard>=0.23", +] + +[project.urls] +Homepage = "https://repo.weispace.net/wei/Launcher" +Repository = "https://repo.weispace.net/wei/Launcher/" + +[tool.uv] +package = false diff --git a/resources/styles/login_dialog.qss b/resources/styles/login_dialog.qss new file mode 100644 index 0000000..65d5417 --- /dev/null +++ b/resources/styles/login_dialog.qss @@ -0,0 +1,33 @@ +QDialog { + background-color: palette(window); +} + +QLabel#dialogTitle { + font-size: 18px; font-weight: 600; +} + +QLabel#helpText { + color: palette(window-text); font-size: 13px; +} + +QLineEdit#codeEdit { + min-height: 50px; + padding: 0 12px; + border-radius: 7px; + background-color: palette(base); + font-size: 25px; + font-weight: 600; + letter-spacing: 2px; +} + +QLabel#errorMessage { + padding: 9px 11px; + border-radius: 6px; + background-color: rgba(210, 55, 55, 28); + color: #c93636; + font-size: 12px; +} + +QPushButton { + min-height: 40px; +} \ No newline at end of file diff --git a/window.py b/window.py index 9cdf6d9..16fa54f 100644 --- a/window.py +++ b/window.py @@ -17,6 +17,7 @@ from pages.account import AccountPage from pages.create_profile import CreateProfilePage from pages.launch_profile import LaunchProfilePage from pages.test import TestPage +from pages.settings import SettingsPage class ToolBar(QtWidgets.QToolBar): def __init__(self, app: QApplication, parent=None): @@ -161,10 +162,14 @@ class LauncherMainWindow(QMainWindow): # Profile page self.create_profile_page = CreateProfilePage(self.app, self) + # Settings page + self.settings_page = SettingsPage(self.app, self) + # Add page self.central.addWidget(self.home_page) self.central.addWidget(self.test_page) self.central.addWidget(self.account_page) + self.central.addWidget(self.settings_page) self.central.addWidget(self.create_profile_page) self.central.addWidget(self.launch_profile_page) @@ -184,13 +189,16 @@ class LauncherMainWindow(QMainWindow): # Launch profile btn self.open_launch_profile_page = self.toolbar.add_button(" Launch Profile", - icon=self.app.get_icon(Path(self.app.icon_dir, "profile_default.png"))) + icon=self.app.get_icon(Path(self.app.icon_dir, "profile_default.png"), True)) # Home btn self.home_button = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) + # Settings btn (will be moved to the bottom of the toolbar) + self.open_settings_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "settings.png"))) + # Account btn (will be moved to the bottom (or last one item) of the toolbar - self.open_account_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"))) + self.open_account_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"), True)) # Profile btn self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add_temp.png"))) @@ -206,6 +214,7 @@ class LauncherMainWindow(QMainWindow): self.toolbar.addWidget(self.toolbar.spacer) self.toolbar.addSeparator() + self.toolbar.addWidget(self.open_settings_page) self.toolbar.addWidget(self.open_account_page) # Bind page-related button click event @@ -221,6 +230,10 @@ class LauncherMainWindow(QMainWindow): lambda: self.set_current_page(self.launch_profile_page, self.open_launch_profile_page) ) + self.open_settings_page.clicked.connect( + lambda: self.set_current_page(self.settings_page, self.open_settings_page) + ) + self.open_account_page.clicked.connect( lambda: self.set_current_page(self.account_page, self.open_account_page) )