Add account manager and the account page is now fully functional.

Fix "Missing javaVersion" error when launching legacy Minecraft.

Add settings page. (But is not fully implemented yet)
This commit is contained in:
wei
2026-08-03 00:16:12 +08:00
parent eff990f7e4
commit 6ab807356d
23 changed files with 3188 additions and 130 deletions
+21
View File
@@ -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.
+37
View File
@@ -1,3 +1,40 @@
# TestLauncher # TestLauncher
# Still thinking about its name...
# There's no stable branch because this project is still under development. # 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)
+2 -3
View File
@@ -5,7 +5,7 @@ __description__ = """
A *Minecraft* launcher that I'm still thinking about its name. 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 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: 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) 2: Be complex at some point (Yeah this rule looks opposite to the first rule)
3: Be cool and fun 3: Be cool and fun
4: No AI slop (Nobody wants to read spaghetti code that made by AI) 4: No AI slop (Nobody wants to read spaghetti code that made by AI)
5: Freedom just like the bird 5: Freedom just like the bird (This project use MIT license)
6: Don't repeat yourself (originated from Django)
""" """
__website__ = "https://repo.weispace.net/wei/Launcher" __website__ = "https://repo.weispace.net/wei/Launcher"
+12 -13
View File
@@ -1,29 +1,28 @@
import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from PySide6 import QtWidgets
from PySide6.QtCore import QSize from PySide6.QtCore import QSize
from PySide6.QtGui import QIcon, Qt, QPixmap from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import QApplication, QWidget, QStyle, QToolButton from PySide6.QtWidgets import QApplication, QStyle
from constant import LAUNCHER_VERSION from constant import LAUNCHER_VERSION
from core_lib.qt.hack import move_window_to_center, is_light_theme, \ from core_lib.qt.hack import move_window_to_center, is_light_theme, \
recolor_icon recolor_icon
from core_lib.qt import messagebox from core_lib.qt import messagebox
from core_lib.launcher.object import Launcher as LauncherObject from core_lib.launcher.object import Launcher as LauncherObject
from manager.account import AccountManager
from manager.game import GameManager from manager.game import GameManager
from manager.profile import ProfileManager from manager.profile import ProfileManager
from manager.widgets import WidgetManager from manager.widgets import WidgetManager
from window import LauncherMainWindow from window import LauncherMainWindow
class Launcher(QApplication, LauncherObject): class Launcher(QApplication, LauncherObject):
def __init__(self, logger): def __init__(self, logger, debug: bool = False):
super().__init__() super().__init__()
# logger # logger
self.logger = logger self.logger = logger
self.debug = debug
# dirs # dirs
self.program_dir = Path(__file__).parent self.program_dir = Path(__file__).parent
@@ -31,10 +30,10 @@ class Launcher(QApplication, LauncherObject):
# Widget (Signal, Event) # Widget (Signal, Event)
self.widget_manager = WidgetManager() self.widget_manager = WidgetManager()
self.game_manager = GameManager(self)
# Manager # Manager
self.profile_manager = ProfileManager(self, self.widget_manager) self.profile_manager = ProfileManager(self, self.widget_manager)
self.account_manager = AccountManager(self, self.widget_manager)
self.game_manager = GameManager(self)
# Window config # Window config
self.setApplicationName("TestLauncher") self.setApplicationName("TestLauncher")
@@ -51,15 +50,15 @@ class Launcher(QApplication, LauncherObject):
self.logger.error("No icon found.") self.logger.error("No icon found.")
def exec(self): def exec(self):
self.main_window.show()
self.main_window.toolbar.update_all()
# init managers # init managers
self.profile_manager.profile_load.emit() self.profile_manager.profile_load.emit()
self.account_manager.account_load.emit()
self.main_window.show()
self.main_window.toolbar.update_all()
self.exec_() 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) icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning)
if not path.exists() or not path.is_file(): if not path.exists() or not path.is_file():
messagebox.error( messagebox.error(
@@ -71,7 +70,7 @@ class Launcher(QApplication, LauncherObject):
return icon return icon
try: 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( icon = recolor_icon(
path path
) )
+9 -1
View File
@@ -1,2 +1,10 @@
LAUNCHER_VERSION = "0.0.3-alpha" LAUNCHER_VERSION = "0.0.4-alpha"
LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)"
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" 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"
+22
View File
@@ -1,4 +1,6 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import logging import logging
import platform import platform
@@ -305,3 +307,23 @@ def is_valid_zipfile(path: Path) -> bool:
return zipfile.is_zipfile(path) return zipfile.is_zipfile(path)
except OSError: except OSError:
return False return False
def bytes_to_data_url(image_data: bytes, mime_type: str = "image/png",) -> str:
"""
Convert raw image bytes to a Base64 Data URL.
:param image_data:
:param mime_type:
:return:
data_url: str
"""
if not isinstance(image_data, bytes):
raise TypeError("image_data must be bytes")
if not image_data:
raise ValueError("image_data cannot be empty")
if not isinstance(mime_type, str) or not mime_type.startswith("image/"):
raise ValueError("mime_type must be a valid image MIME type")
encoded = base64.b64encode(image_data).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
+24
View File
@@ -87,3 +87,27 @@ class ProfileLoadException(LauncherException):
class ProfileKeyNotFoundException(LauncherException): class ProfileKeyNotFoundException(LauncherException):
user_message = "Specified profile key not found. Profile may corrupted." user_message = "Specified profile key not found. Profile may corrupted."
class AccountException(LauncherException):
user_message = "An error occurred while processing account data."
class AccountSaveException(AccountException):
user_message = "Unable to save account data. Try again later."
class AccountLoadException(AccountException):
user_message = "Unable to load account data. The account file may be corrupted."
class AccountKeyNotFoundException(AccountException):
user_message = "Specified account was not found."
class AuthenticationException(LauncherException):
user_message = "Authentication failed."
class AuthenticationConnectException(LauncherException):
user_message = "An error occurred while connecting to authentication server."
class AuthenticationSessionException(LauncherException):
user_message = "Authentication failed. Session may expire."
class ThirdPartyAPIException(LauncherException):
user_message = "An error occurred while fetching third party API."
+539
View File
@@ -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
+464
View File
@@ -0,0 +1,464 @@
"""
Store all function for microsoft authentication
WARNING: This module is vibed! With code reviewed!
"""
import logging
import time
import traceback
from typing import Callable
import requests
from core_lib.common.exception import AuthenticationSessionException, AuthenticationConnectException, \
AuthenticationException
MICROSOFT_DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
XBOX_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"
MINECRAFT_LOGIN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"
MINECRAFT_ENTITLEMENTS_URL = "https://api.minecraftservices.com/entitlements/mcstore"
MINECRAFT_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"
logger = logging.getLogger("Launcher.CoreLib")
def auth_exception_handler(request_handler: Callable, request_type: str = "without_token", stage: str = "authentication",):
"""
Handle authentication exceptions
:param stage:
:param request_handler:
:param request_type: Set this will change exception type when an error occurs. ("without_token" for process that
don't use token, "with_token" for process that use token. If with token, exception usually throw
AuthenticationSessionException. If not, throw AuthenticationException instead)
:return:
"""
exception_type = (
AuthenticationSessionException
if request_type == "with_token"
else AuthenticationException
)
try:
return request_handler()
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "unknown"
response_data = {}
if e.response is not None:
try:
value = e.response.json()
if isinstance(value, dict):
response_data = value
except ValueError:
pass
xerr = response_data.get("XErr")
try:
xerr = int(xerr) if xerr is not None else None
except (TypeError, ValueError):
pass
# Below are the xsts error code suggestions. Why return that value (error is integer not string) ?
# Maybe it makes international support more easily?
xsts_suggestions = {
2148916233: "This Microsoft account does not have an Xbox profile. Sign in at xbox.com and create one, then try again.",
2148916235: "Xbox Live is not available for this account's country or region.",
2148916236: "This account must complete adult verification on the Xbox website.",
2148916237: "This account must complete age verification on the Xbox website.",
2148916238: "This is a child account. An adult must add it to a Microsoft family and allow Xbox access.",
}
if xerr in xsts_suggestions:
raise exception_type(
f"{stage} failed with HTTP {status}, XErr {xerr}",
user_message=f"{stage} failed (Xbox error {xerr}). {xsts_suggestions[xerr]}",
) from e
server_message = response_data.get("Message") or response_data.get("error_description")
# This suggestions can make user happy instead of seeing an HTTP error code
suggestions = {
400: "The authentication request or session was rejected.",
401: "The login session is invalid or expired. Sign in again.",
403: "This account is not allowed to complete this step. Check Xbox privacy settings and account restrictions."
" Or check the client id has authorized to use Minecraft API.",
429: "The authentication service received too many requests. Wait a moment and try again.",
}
suggestion = suggestions.get(
status,
"The authentication service returned an unexpected response. Try again later.",
)
if server_message:
suggestion = f"{suggestion} Server message: {server_message}"
raise exception_type(f"{stage} failed with HTTP status {status}: {e}",
user_message=f"{stage} failed (HTTP {status}). {suggestion}",
) from e
except requests.exceptions.ConnectionError as e:
raise AuthenticationConnectException(
f"Unable to connect during {stage}: {e}",
user_message=f"{stage} could not connect to the authentication server. Check your internet connection, proxy, and firewall.",
) from e
except requests.exceptions.Timeout as e:
raise AuthenticationConnectException(
f"Request timed out during {stage}: {e}",
user_message=f"{stage} timed out. Check your connection and try again later.",
) from e
except requests.exceptions.RequestException as e:
raise exception_type(
f"Request error during {stage}: {e}",
user_message=f"{stage} failed because the authentication request could not be completed: {e}",
) from e
except AuthenticationException:
raise
except Exception as e:
raise exception_type(
f"Unexpected error during {stage}: {traceback.format_exc()}",
user_message=f"{stage} failed because the server returned invalid or unexpected data.",
) from e
def get_device_code(client_id: str) -> tuple[str, dict]:
"""
Get xbox live device code
:param client_id:
:return:
device_code: str
json_data: dict
"""
def request_handler():
body = {
"client_id": client_id,
"scope": "XboxLive.signin offline_access",
}
# Microsoft OAuth endpoints require form-encoded request bodies.
r = requests.post(MICROSOFT_DEVICE_CODE_URL, data=body)
r.raise_for_status()
return r.json()
data = auth_exception_handler(request_handler, stage="Requesting a Microsoft sign-in code")
if not data.get("device_code"):
raise AuthenticationException(
"Authentication response missing device_code",
user_message="Microsoft did not return a sign-in code. Check the client ID or update the launcher.",
)
return data["device_code"], data
def get_microsoft_token(client_id: str, device_code: str, interval: int, expires_in: float, cancelled=None) -> tuple[str, str, dict]:
"""
Get microsoft token (with login wait)
:param client_id:
:param device_code:
:param interval:
:param expires_in:
:param cancelled:
:return:
access_token: str
refresh_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(MICROSOFT_TOKEN_URL, data={
"client_id": client_id,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
})
return r.status_code, r.json()
def verify_data(result: dict) -> tuple[str, str, dict]:
access_token = result.get("access_token")
refresh_token = result.get("refresh_token")
if not access_token:
raise AuthenticationException(
"Authentication response missing access_token",
user_message="Microsoft approved the sign-in but did not return an access token. Please sign in again.",
)
if not refresh_token:
raise AuthenticationException(
"Authentication response missing refresh_token",
user_message="Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.",
)
return access_token, refresh_token, result
deadline = time.monotonic() + expires_in
while time.monotonic() < deadline:
# cancel callback
if cancelled and cancelled():
raise InterruptedError("Login cancelled.")
time.sleep(interval)
status, data = auth_exception_handler(_request_handler, stage="Waiting for Microsoft sign-in approval")
if status == 200:
return verify_data(data)
error = data.get("error")
if error == "authorization_pending":
continue
if error == "slow_down":
interval += 5
continue
description = data.get("error_description") or data.get("error") or "No details were provided"
raise AuthenticationException(
f"Microsoft token request failed with HTTP {status}: {description}",
user_message=f"Microsoft sign-in was rejected (HTTP {status}): {description}",
)
raise TimeoutError("Microsoft sign-in code expired before login was completed. Start the sign-in process again.")
def get_xbox_token_and_user_hash(access_token: str) -> tuple[str, str, dict]:
"""
Get xbox live token and user hash
:param access_token: Microsoft account access token
:return:
xbox_token: str
user_hash: str
json_data: dict
"""
def _request_handler():
r = requests.post(XBOX_AUTH_URL, json={
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": f"d={access_token}",
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
}, headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, str, dict]:
xbox_token = result.get("Token")
user_hash = None
try:
user_hash = result.get("DisplayClaims", {}).get("xui", {})[0].get("uhs")
except Exception as e:
logger.error("Unable to get user hash from data {}: {}".format(result, e))
if not xbox_token:
raise AuthenticationException(
"Authentication response missing xbox_token",
user_message="Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.",
)
if not user_hash:
raise AuthenticationException(
"Authentication response missing user_hash",
user_message="Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.",
)
return xbox_token, user_hash, result
data = auth_exception_handler(_request_handler, stage="Signing in to Xbox Live")
return verify_data(data)
def get_xsts_token(xbox_token: str) -> tuple[str, dict]:
"""
Get xsts token
:param xbox_token:
:return:
xsts_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(XSTS_AUTH_URL, json={
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbox_token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
}, headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, dict]:
xsts_token = result.get("Token")
if not xsts_token:
raise AuthenticationException(
"Authentication response missing xsts_token",
user_message="Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.",
)
return xsts_token, result
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Authorizing the Xbox session for Minecraft",
)
return verify_data(data)
def get_minecraft_access_token(user_hash: str, xsts_token: str) -> tuple[str, dict]:
"""
Get minecraft access token
:param user_hash: Xbox live user hash
:param xsts_token: XSTS token
Above param can get from get_xbox_token_and_user_hash and get_xsts_token
:return:
access_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(MINECRAFT_LOGIN_URL, json={"identityToken": f"XBL3.0 x={user_hash};{xsts_token}"},
headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, dict]:
access_token = result.get("access_token")
if not access_token:
raise AuthenticationException(
"Authentication response missing access token",
user_message="Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.",
)
return access_token, result
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Signing in to Minecraft services",
)
return verify_data(data)
def get_account_entitlements(minecraft_access_token: str) -> dict:
"""
Get minecraft account entitlements
:param minecraft_access_token:
:return:
"""
def _request_handler():
response = requests.get(
MINECRAFT_ENTITLEMENTS_URL,
headers={"Authorization": f"Bearer {minecraft_access_token}"},
)
response.raise_for_status()
return response.json()
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Checking Minecraft ownership",
)
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise AuthenticationSessionException(
"Minecraft entitlement response is missing the items list",
user_message="Minecraft services returned an invalid ownership response. Please sign in again or try later.",
)
return data
def get_minecraft_profile(minecraft_access_token: str) -> dict:
"""
Get minecraft profile
:param minecraft_access_token:
:return:
profile_data: dict
"""
def _request_handler():
response = requests.get(
MINECRAFT_PROFILE_URL,
headers={"Authorization": f"Bearer {minecraft_access_token}"},
)
response.raise_for_status()
return response.json()
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Loading the Minecraft profile",
)
if not isinstance(data, dict) or not data.get("id") or not data.get("name"):
raise AuthenticationSessionException(
"Minecraft profile response is missing id or name",
user_message="Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.",
)
return data
def has_minecraft_license(entitlements: dict) -> bool:
"""
Check if provided account entitlements have minecraft license
:param entitlements:
:return:
found: bool
"""
if not isinstance(entitlements, dict):
raise AuthenticationException(
f"Expected Minecraft entitlements to be a dictionary, got {type(entitlements).__name__}",
user_message="Unable to verify Minecraft ownership because the ownership response is invalid.",
)
items = entitlements.get("items")
if not isinstance(items, list):
raise AuthenticationException(
"Minecraft entitlement response is missing a valid items list",
user_message="Unable to verify Minecraft ownership because the ownership list is missing or invalid.",
)
return any(
isinstance(item, dict)
and item.get("name") in {"game_minecraft", "product_minecraft"}
for item in items
)
def refresh_microsoft_token(client_id: str, refresh_token: str) -> tuple[str, str, dict]:
"""
Use existing microsoft refresh token to get new (or extend the expires time) refresh token
Returns:
microsoft_access_token,
new_refresh_token,
response_data
"""
def request_handler():
response = requests.post(
MICROSOFT_TOKEN_URL,
data={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "XboxLive.signin offline_access",
},
timeout=30,
)
response.raise_for_status()
return response.json()
result = auth_exception_handler(
request_handler,
request_type="with_token",
stage="Refreshing Microsoft credentials",
)
access_token = result.get("access_token")
new_refresh_token = result.get("refresh_token")
if not access_token:
raise AuthenticationSessionException(
"Refresh response missing access_token",
user_message=(
"Microsoft could not refresh this account. "
"Please sign in again."
),
)
# Return original refresh token if new token is not show up in response
return (
access_token,
new_refresh_token or refresh_token,
result,
)
+10
View File
@@ -9,6 +9,8 @@ import uuid
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from PySide6.QtGui import QImage
from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \ from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \
ProfileLoadException 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: 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) profile = get_profile(profiles, profile_id)
created: str | datetime.datetime = profile.get("created", datetime.datetime.now()) created: str | datetime.datetime = profile.get("created", datetime.datetime.now())
+53
View File
@@ -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.",
)
+3 -3
View File
@@ -122,7 +122,7 @@ def get_version_manifest(manifest_url=MOJANG_VERSION_MANIFEST_V2, timeout=10) ->
try: try:
data = r.json() data = r.json()
logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4))) # logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4)))
return data return data
except ValueError as e: except ValueError as e:
raise VersionManifestFetchException( raise VersionManifestFetchException(
@@ -180,10 +180,10 @@ def get_all_versions(version_manifest: dict, is_snapshot=False, include_all_type
filtered = [] filtered = []
for ver in all_vers: for ver in all_vers:
ver_id = ver.get("id", None) # ver_id = ver.get("id", None)
ver_type = ver.get("type") 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: if ver_type == target_type or include_all_type:
filtered.append(ver) filtered.append(ver)
+25 -3
View File
@@ -1,11 +1,12 @@
import base64
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor, QImage
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget 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() main_screen = QApplication.primaryScreen()
center = QScreen.availableGeometry(main_screen).center() center = QScreen.availableGeometry(main_screen).center()
geometry = window.frameGeometry() geometry = window.frameGeometry()
@@ -102,4 +103,25 @@ def add_icon_padding(icon: QIcon, icon_size=32, padding=6) -> QIcon:
return QIcon(canvas) 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 ======================== # ======================== AI generated END ========================
+15 -1
View File
@@ -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 os
import sys import sys
import argparse import argparse
@@ -50,7 +64,7 @@ def main():
logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level) logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level)
# Start (the real) launcher # Start (the real) launcher
app = Launcher(logger) app = Launcher(logger, debug=debug)
sys.exit(app.exec()) sys.exit(app.exec())
+1194
View File
File diff suppressed because it is too large Load Diff
+37 -9
View File
@@ -8,7 +8,9 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from PySide6.QtCore import QObject 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.common import FileObject
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \ from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \
NoSpecifiedVersionKeyException 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, \ save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \
LATEST_VERSION_ALIASES LATEST_VERSION_ALIASES
from core_lib.runtime.java import find_java_installations from core_lib.runtime.java import find_java_installations
from manager.account import AccountManager, LaunchAccount
from manager.profile import LaunchProfile from manager.profile import LaunchProfile
from manager.widgets import SelectPathRequest, AskForRequest from manager.widgets import SelectPathRequest, AskForRequest
@@ -411,11 +414,12 @@ class LaunchResult:
self.warnings.append(message) self.warnings.append(message)
class LaunchManager: 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.app = app
self.manifest_mgr = manifest_manager self.manifest_mgr = manifest_manager
self.widget_mgr = app.widget_manager self.widget_mgr = app.widget_manager
self.java_mgr = java_manager self.java_mgr = java_manager
self.account_mgr = account_manager
self.launched_profiles: list[str] = [] self.launched_profiles: list[str] = []
def launch(self, profile: LaunchProfile) -> LaunchResult: def launch(self, profile: LaunchProfile) -> LaunchResult:
@@ -463,6 +467,15 @@ class LaunchManager:
if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir): if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir):
download_full_libraries(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 # Asset
default_assets_dir = Path(self.app.work_dir, "assets") 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): if not check_assets_exist(version_manifest, default_assets_dir, launcher_root=self.app.work_dir):
@@ -498,19 +511,20 @@ class LaunchManager:
# Mappings # Mappings
arg_maps = ArgumentMappings( arg_maps = ArgumentMappings(
player_name="Player", player_name=launch_account.player_name,
version_name=version_id, version_name=version_id,
game_directory=game_dir.as_posix(), game_directory=game_dir.as_posix(),
assets_root=assets_dir.as_posix(), assets_root=assets_dir.as_posix(),
legacy_assets_root=assets_dir, legacy_assets_root=assets_dir,
assets_index_name=asset_id, assets_index_name=asset_id,
auth_uuid="00000000000000000000000000000000", auth_uuid=launch_account.minecraft_uuid,
auth_access_token="0", auth_access_token=launch_account.access_token,
user_type="legacy", clientid=CLIENT_ID,
user_type=launch_account.user_type,
version_type=profile.version_type, version_type=profile.version_type,
natives_directory=natives_dir.as_posix(), natives_directory=natives_dir.as_posix(),
launcher_name="TestLauncher", launcher_name="TestLauncher",
launcher_version="0.1", launcher_version=LAUNCHER_VERSION,
classpath=classpath, classpath=classpath,
classpath_separator=os.pathsep, classpath_separator=os.pathsep,
library_directory=libraries_dir.as_posix(), library_directory=libraries_dir.as_posix(),
@@ -553,15 +567,18 @@ class LaunchManager:
self.app.logger.debug("Use system environment java.") self.app.logger.debug("Use system environment java.")
java_path = "java" java_path = "java"
self.app.logger.debug(f"Java executable path is: {java_path}")
# Finally, generate launch command # Finally, generate launch command
cmd = generate_launch_command( cmd = generate_launch_command(
arg_maps=arg_maps, arg_maps=arg_maps,
main_class=main_class, main_class=main_class,
java_path=java_path, java_path=java_path,
full_args=target_args, full_args=target_args
) )
self.app.logger.debug(f"Launch command: {cmd}") safe_cmd = ["<access token>" if value == launch_account.access_token else value for value in cmd]
self.app.logger.debug(f"Launch command: {safe_cmd}")
# run it # run it
process = subprocess.Popen( process = subprocess.Popen(
@@ -588,11 +605,22 @@ class LaunchManager:
def is_profile_running(self, profile_id): def is_profile_running(self, profile_id):
return profile_id in self.launched_profiles 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): class GameManager(QObject):
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
super(GameManager, self).__init__(parent) super(GameManager, self).__init__(parent)
self.app = app self.app = app
self.account_manager = app.account_manager
self.widget_mgr = app.widget_manager self.widget_mgr = app.widget_manager
self.manifest = ManifestManager(self.app) self.manifest = ManifestManager(self.app)
self.java = JavaManager(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)
+292 -44
View File
@@ -1,31 +1,52 @@
from __future__ import annotations
import webbrowser
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, QSize from PySide6.QtCore import QSize, Qt, Signal, QTimer
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap, QFontDatabase
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QListWidget, QPushButton, QHBoxLayout, QListWidgetItem, \ from PySide6.QtWidgets import (
QMenu 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): class AccountWidget(QWidget):
def __init__(self, account_name, account_head: QPixmap, login_status, parent=None, 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): /, delete_account_callback=None, check_account_callback=None, switch_account_callback=None,):
QWidget.__init__(self, parent) super().__init__(parent)
# Layout
self.layout = QHBoxLayout(self) self.layout = QHBoxLayout(self)
# Widgets
self.head_icon_label = QLabel() self.head_icon_label = QLabel()
self.head_icon_label.setObjectName("HeadIcon") self.head_icon_label.setObjectName("HeadIcon")
self.head_icon_label.setFixedSize(account_head.size())
self.head_icon_label.setPixmap(account_head) self.head_icon_label.setPixmap(account_head)
self.account_name = QLabel(account_name) self.account_name = QLabel(account_name)
self.status_label = QLabel(login_status) self.status_label = QLabel(login_status)
# Bind widgets
self.layout.addWidget(self.head_icon_label) self.layout.addWidget(self.head_icon_label)
self.layout.addWidget(self.account_name) self.layout.addWidget(self.account_name)
self.layout.addStretch() self.layout.addStretch()
self.layout.addWidget(self.status_label) self.layout.addWidget(self.status_label)
# Callback
self.delete_account_callback = delete_account_callback self.delete_account_callback = delete_account_callback
self.check_account_callback = check_account_callback self.check_account_callback = check_account_callback
self.switch_account_callback = switch_account_callback self.switch_account_callback = switch_account_callback
@@ -34,67 +55,294 @@ class AccountWidget(QWidget):
self.customContextMenuRequested.connect(self.show_right_click_menu) self.customContextMenuRequested.connect(self.show_right_click_menu)
def show_right_click_menu(self, position): 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") if selected == delete_action and callable(self.delete_account_callback):
check_log_stat_action = context_menu.addAction("Check login status") 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: # Button
# handle callback here self.copy_code_button = QPushButton("Copy code")
if callable(self.delete_account_callback): self.delete_account_callback() self.copy_code_button.setObjectName("copyCodeButton")
elif selected_action == check_log_stat_action and callable(self.check_account_callback): self.check_account_callback() self.open_browser_button = QPushButton("Open browser")
elif selected_action == switch_acc_action and callable(self.switch_account_callback): self.switch_account_callback() 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): class AccountPage(QWidget):
account_authenticated = Signal(dict)
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
QWidget.__init__(self, parent) super().__init__(parent)
self.app = app 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() # Layout
self.right_layout = QVBoxLayout() main_layout = QVBoxLayout()
right_layout = QVBoxLayout()
layout = QHBoxLayout(self)
# Widgets
self.account_list = QListWidget(self) self.account_list = QListWidget(self)
self.add_account_button = QPushButton("Add Account") self.add_account_button = QPushButton("Add Account")
self.refresh_button = QPushButton("Refresh all") self.refresh_button = QPushButton("Refresh all")
self.delete_all_button = QPushButton("Delete All") self.delete_all_button = QPushButton("Delete All")
self.main_layout.addWidget(self.account_list) # Button click handle
self.right_layout.addWidget(self.add_account_button) self.add_account_button.clicked.connect(self.start_login_page)
self.right_layout.addWidget(self.refresh_button) self.refresh_button.clicked.connect(self.refresh_accounts)
self.right_layout.addWidget(self.delete_all_button) self.delete_all_button.clicked.connect(self.account_manager.delete_all_accounts)
self.right_layout.addStretch()
self.layout.addLayout(self.main_layout) # Bind widget
self.layout.addLayout(self.right_layout) 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): def _render_accounts(self):
head_icon = self.app.get_icon(Path(self.app.icon_dir, "head.png")) self.account_list.clear()
for i in range(1, 11):
name = f"Player_{i}" current_id = self.account_manager.get_current_account_id()
head_pixmap = head_icon.pixmap(QSize(50, 50)) 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 = QListWidgetItem()
list_item.setData(Qt.ItemDataRole.UserRole, account.id)
def delete(): head_size = QSize(32, 32)
self.account_list.removeItemWidget(list_item) widget = AccountWidget(
item = AccountWidget(name, head_pixmap, self, delete_account_callback=delete) account.display_name,
list_item.setSizeHint(item.sizeHint()) 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.addItem(list_item)
self.account_list.setItemWidget(list_item, 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)
+5 -1
View File
@@ -197,7 +197,7 @@ class LaunchProfilePage(QWidget):
self.bottom_layout = QHBoxLayout() self.bottom_layout = QHBoxLayout()
# Widgets # 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.setStyleSheet("font-size: 12px; font-weight: 600;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -363,6 +363,7 @@ class LaunchProfilePage(QWidget):
self.profile_button.set_popup_open(False) self.profile_button.set_popup_open(False)
def launch_profile(self) -> None: def launch_profile(self) -> None:
try:
profile_id = self.profile_button.property("profile_id") profile_id = self.profile_button.property("profile_id")
self.status_label.setText("Preparing to launch...") self.status_label.setText("Preparing to launch...")
launch_profile = self.app.profile_manager.get_launch_profile(profile_id) launch_profile = self.app.profile_manager.get_launch_profile(profile_id)
@@ -398,6 +399,7 @@ class LaunchProfilePage(QWidget):
launch_profile.profile_id, launch_profile.profile_id,
title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})", title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})",
parent=None, parent=None,
finished_callback=self.launch_manager.profile_finished,
) )
for warning in result.warnings: for warning in result.warnings:
log_window.append_message(f"[launcher warning] {warning}") log_window.append_message(f"[launcher warning] {warning}")
@@ -410,3 +412,5 @@ class LaunchProfilePage(QWidget):
result.error_message, result.error_message,
) )
) )
finally:
self.status_label.setText("Select a profile to launch")
+296
View File
@@ -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)
+3 -2
View File
@@ -23,7 +23,7 @@ from core_lib.game.version_info import (find_useful_part_in_specific_version_man
download_core_file) download_core_file)
from core_lib.qt.hack import is_main_thread from core_lib.qt.hack import is_main_thread
from core_lib.runtime.java import find_java_installations 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 from manager.widgets import SelectPathRequest, AskForRequest
MAX_DOWNLOAD_ATTEMPTS = 3 MAX_DOWNLOAD_ATTEMPTS = 3
@@ -37,6 +37,7 @@ class TestPage(QWidget):
self.parent = parent self.parent = parent
self.widget_mgr = widget_manager self.widget_mgr = widget_manager
self.manifest_mgr: ManifestManager = app.game_manager.manifest self.manifest_mgr: ManifestManager = app.game_manager.manifest
self.java_mgr: JavaManager = app.game_manager.java
self.resize(800, 600) self.resize(800, 600)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint)
@@ -781,7 +782,7 @@ class TestPage(QWidget):
return return
# Use found java runtime (if available) # 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, java_major_version,
no_error=True no_error=True
) )
+29
View File
@@ -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 = "[email protected]" },
]
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
+33
View File
@@ -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;
}
+15 -2
View File
@@ -17,6 +17,7 @@ from pages.account import AccountPage
from pages.create_profile import CreateProfilePage from pages.create_profile import CreateProfilePage
from pages.launch_profile import LaunchProfilePage from pages.launch_profile import LaunchProfilePage
from pages.test import TestPage from pages.test import TestPage
from pages.settings import SettingsPage
class ToolBar(QtWidgets.QToolBar): class ToolBar(QtWidgets.QToolBar):
def __init__(self, app: QApplication, parent=None): def __init__(self, app: QApplication, parent=None):
@@ -161,10 +162,14 @@ class LauncherMainWindow(QMainWindow):
# Profile page # Profile page
self.create_profile_page = CreateProfilePage(self.app, self) self.create_profile_page = CreateProfilePage(self.app, self)
# Settings page
self.settings_page = SettingsPage(self.app, self)
# Add page # Add page
self.central.addWidget(self.home_page) self.central.addWidget(self.home_page)
self.central.addWidget(self.test_page) self.central.addWidget(self.test_page)
self.central.addWidget(self.account_page) self.central.addWidget(self.account_page)
self.central.addWidget(self.settings_page)
self.central.addWidget(self.create_profile_page) self.central.addWidget(self.create_profile_page)
self.central.addWidget(self.launch_profile_page) self.central.addWidget(self.launch_profile_page)
@@ -184,13 +189,16 @@ class LauncherMainWindow(QMainWindow):
# Launch profile btn # Launch profile btn
self.open_launch_profile_page = self.toolbar.add_button(" Launch Profile", 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 # Home btn
self.home_button = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) 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 # 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 # Profile btn
self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add_temp.png"))) 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.addWidget(self.toolbar.spacer)
self.toolbar.addSeparator() self.toolbar.addSeparator()
self.toolbar.addWidget(self.open_settings_page)
self.toolbar.addWidget(self.open_account_page) self.toolbar.addWidget(self.open_account_page)
# Bind page-related button click event # 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) 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( self.open_account_page.clicked.connect(
lambda: self.set_current_page(self.account_page, self.open_account_page) lambda: self.set_current_page(self.account_page, self.open_account_page)
) )