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:
@@ -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.
|
||||
@@ -1,3 +1,40 @@
|
||||
# TestLauncher
|
||||
|
||||
# There's no stable branch because this project is still under development.
|
||||
# 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)
|
||||
+2
-3
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+10
-2
@@ -1,2 +1,10 @@
|
||||
LAUNCHER_VERSION = "0.0.3-alpha"
|
||||
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
|
||||
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"
|
||||
@@ -1,4 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import platform
|
||||
@@ -304,4 +306,24 @@ def is_valid_zipfile(path: Path) -> bool:
|
||||
try:
|
||||
return zipfile.is_zipfile(path)
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def bytes_to_data_url(image_data: bytes, mime_type: str = "image/png",) -> str:
|
||||
"""
|
||||
Convert raw image bytes to a Base64 Data URL.
|
||||
:param image_data:
|
||||
:param mime_type:
|
||||
:return:
|
||||
data_url: str
|
||||
"""
|
||||
if not isinstance(image_data, bytes):
|
||||
raise TypeError("image_data must be bytes")
|
||||
|
||||
if not image_data:
|
||||
raise ValueError("image_data cannot be empty")
|
||||
|
||||
if not isinstance(mime_type, str) or not mime_type.startswith("image/"):
|
||||
raise ValueError("mime_type must be a valid image MIME type")
|
||||
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
@@ -86,4 +86,28 @@ class ProfileLoadException(LauncherException):
|
||||
user_message = "Unable to load profile. Profile may corrupted"
|
||||
|
||||
class ProfileKeyNotFoundException(LauncherException):
|
||||
user_message = "Specified profile key not found. Profile may corrupted."
|
||||
user_message = "Specified profile key not found. Profile may corrupted."
|
||||
|
||||
class AccountException(LauncherException):
|
||||
user_message = "An error occurred while processing account data."
|
||||
|
||||
class AccountSaveException(AccountException):
|
||||
user_message = "Unable to save account data. Try again later."
|
||||
|
||||
class AccountLoadException(AccountException):
|
||||
user_message = "Unable to load account data. The account file may be corrupted."
|
||||
|
||||
class AccountKeyNotFoundException(AccountException):
|
||||
user_message = "Specified account was not found."
|
||||
|
||||
class AuthenticationException(LauncherException):
|
||||
user_message = "Authentication failed."
|
||||
|
||||
class AuthenticationConnectException(LauncherException):
|
||||
user_message = "An error occurred while connecting to authentication server."
|
||||
|
||||
class AuthenticationSessionException(LauncherException):
|
||||
user_message = "Authentication failed. Session may expire."
|
||||
|
||||
class ThirdPartyAPIException(LauncherException):
|
||||
user_message = "An error occurred while fetching third party API."
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
A launcher profile module. Contains many useful function to manage minecraft account
|
||||
INFO: The account data generated by this module is not compatible with official launcher profile!
|
||||
|
||||
This module is vibed too!
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from core_lib.common.common import bytes_to_data_url
|
||||
from core_lib.common.exception import (
|
||||
AccountException,
|
||||
AccountKeyNotFoundException,
|
||||
AccountLoadException,
|
||||
AccountSaveException,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("Launcher.CoreLib")
|
||||
|
||||
|
||||
def get_account_sample() -> dict:
|
||||
return {"accounts": {}, "activeAccountId": None}
|
||||
|
||||
|
||||
get_accounts_sample = get_account_sample
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
account_id: str
|
||||
username: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
access_token_expires_at: str | datetime.datetime
|
||||
refresh_token_expires_at: str | datetime.datetime
|
||||
minecraft_uuid: str = ""
|
||||
minecraft_name: str = ""
|
||||
avatar: str = ""
|
||||
type: str = "msa"
|
||||
user_properties: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _datetime_string(value: str | datetime.datetime, field_name: str) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
raise TypeError(f'"{field_name}" must be a datetime.datetime or str')
|
||||
|
||||
|
||||
def create_account_data_from_object(account: Account) -> dict:
|
||||
"""
|
||||
Convert Account object to dictionary
|
||||
:param account:
|
||||
:return:
|
||||
account_dict: dict
|
||||
"""
|
||||
return {
|
||||
"accessToken": account.access_token,
|
||||
"accessTokenExpiresAt": _datetime_string(
|
||||
account.access_token_expires_at, "access_token_expires_at"
|
||||
),
|
||||
"refreshToken": account.refresh_token,
|
||||
"refreshTokenExpiresAt": _datetime_string(
|
||||
account.refresh_token_expires_at, "refresh_token_expires_at"
|
||||
),
|
||||
"avatar": account.avatar,
|
||||
"accountID": account.account_id,
|
||||
"minecraftProfile": {
|
||||
"uuid": account.minecraft_uuid,
|
||||
"name": account.minecraft_name,
|
||||
},
|
||||
"type": account.type,
|
||||
"userProperties": deepcopy(account.user_properties),
|
||||
"username": account.username,
|
||||
}
|
||||
|
||||
SUPPORT_DATA_URL_IMAGE_TYPE = [
|
||||
"image/apng",
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
"image/vnd.microsoft.icon", # .ico
|
||||
"image/webp",
|
||||
]
|
||||
|
||||
def is_valid_account_avatar(avatar: str | bytes, support_type: list[str] | None=None) -> bool:
|
||||
if isinstance(avatar, bytes):
|
||||
try:
|
||||
avatar = bytes_to_data_url(avatar)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
if not isinstance(avatar, str):
|
||||
return False
|
||||
|
||||
# For empty avatar (not set)
|
||||
if isinstance(avatar, str) and not avatar:
|
||||
return True
|
||||
|
||||
if support_type is None:
|
||||
support_type = SUPPORT_DATA_URL_IMAGE_TYPE
|
||||
|
||||
try:
|
||||
header, encoded_data = avatar.split(",", 1)
|
||||
except ValueError:
|
||||
# For keyword avatar (This need launcher to replace the keyword to mapped image)
|
||||
return True
|
||||
|
||||
# Check header
|
||||
if not header.startswith("data:image/"):
|
||||
return False
|
||||
|
||||
if not header.endswith(";base64"):
|
||||
return False
|
||||
|
||||
image_type = header.removeprefix("data:").removesuffix(";base64")
|
||||
|
||||
if image_type not in support_type:
|
||||
logger.info(f"Unsupported image type: {image_type}")
|
||||
return False
|
||||
|
||||
# Try to decode the avatar
|
||||
try:
|
||||
base64.b64decode(encoded_data, validate=True)
|
||||
return True
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
logger.info(f"Unable to decode account avatar: {exc}")
|
||||
|
||||
return False
|
||||
|
||||
def _check_account(account: dict, account_id: str | None = None, fix_wrong: bool = False) -> bool:
|
||||
"""
|
||||
Check if an account is valid.
|
||||
:param account:
|
||||
:param account_id:
|
||||
:param fix_wrong:
|
||||
:return:
|
||||
is_valid: bool
|
||||
"""
|
||||
if not isinstance(account, dict):
|
||||
return False
|
||||
|
||||
required_strings = (
|
||||
"accessToken",
|
||||
"accessTokenExpiresAt",
|
||||
"refreshToken",
|
||||
"refreshTokenExpiresAt",
|
||||
"accountID",
|
||||
"type",
|
||||
"username",
|
||||
)
|
||||
|
||||
# Check all required key is exiting
|
||||
if any(not isinstance(account.get(key), str) or not account[key] for key in required_strings):
|
||||
return False
|
||||
|
||||
# Check account id is match as provided
|
||||
if account_id is not None and account["accountID"] != account_id:
|
||||
return False
|
||||
|
||||
# Verify profile data (Must contain uuid and name)
|
||||
minecraft_profile = account.get("minecraftProfile")
|
||||
if not isinstance(minecraft_profile, dict):
|
||||
return False
|
||||
if any(not isinstance(minecraft_profile.get(key), str) for key in ("uuid", "name")):
|
||||
return False
|
||||
|
||||
# User properties (Left from official launcher, maybe this field will put some custom account settings in the feture
|
||||
properties = account.get("userProperties")
|
||||
if properties is None and fix_wrong:
|
||||
account["userProperties"] = []
|
||||
elif not isinstance(properties, list):
|
||||
return False
|
||||
|
||||
# Account avatar
|
||||
avatar = account.get("avatar")
|
||||
if avatar is None and fix_wrong:
|
||||
account["avatar"] = ""
|
||||
elif not isinstance(avatar, str):
|
||||
return False
|
||||
|
||||
if not is_valid_account_avatar(avatar):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_account_valid(account_data: dict, fix_wrong: bool = False) -> bool:
|
||||
return _check_account(account_data, fix_wrong=fix_wrong)
|
||||
|
||||
|
||||
def is_accounts_valid(accounts: dict, fix_wrong: bool = False, ignore_invalid_account: bool = False) -> bool:
|
||||
"""
|
||||
Check if accountData (contain multiple account and some settings) is valid.
|
||||
:param accounts:
|
||||
:param fix_wrong:
|
||||
:param ignore_invalid_account:
|
||||
:return:
|
||||
is_valid: bool
|
||||
"""
|
||||
if not isinstance(accounts, dict) or not isinstance(accounts.get("accounts"), dict):
|
||||
return False
|
||||
|
||||
account_map = accounts["accounts"]
|
||||
active_id = accounts.get("activeAccountId")
|
||||
|
||||
if active_id is not None and not isinstance(active_id, str):
|
||||
return False
|
||||
|
||||
invalid_ids = [
|
||||
account_id
|
||||
for account_id, account in account_map.items()
|
||||
if not isinstance(account_id, str)
|
||||
or not _check_account(account, account_id=account_id, fix_wrong=fix_wrong)
|
||||
]
|
||||
|
||||
if invalid_ids and not ignore_invalid_account:
|
||||
return False
|
||||
|
||||
for account_id in invalid_ids:
|
||||
account_map.pop(account_id, None)
|
||||
|
||||
if active_id is not None and active_id not in account_map:
|
||||
if fix_wrong or ignore_invalid_account:
|
||||
accounts["activeAccountId"] = next(iter(account_map), None)
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def list_accounts(accounts: dict) -> dict:
|
||||
"""
|
||||
Get account map from accountData.
|
||||
:param accounts:
|
||||
:return:
|
||||
"""
|
||||
value = accounts.get("accounts", {}) if isinstance(accounts, dict) else {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def is_account_exists(accounts: dict, account_id: str) -> bool:
|
||||
"""
|
||||
Check if an account exists in accountData.
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
"""
|
||||
return account_id in list_accounts(accounts)
|
||||
|
||||
|
||||
def get_account(accounts: dict, account_id: str) -> dict:
|
||||
"""
|
||||
Get an account from accountData.
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
account_dict: dict
|
||||
"""
|
||||
try:
|
||||
return list_accounts(accounts)[account_id]
|
||||
except KeyError as exc:
|
||||
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.") from exc
|
||||
|
||||
|
||||
def get_current_account_id(accounts: dict) -> str | None:
|
||||
"""
|
||||
Get current account id from accountData.
|
||||
:param accounts:
|
||||
:return:
|
||||
account_dict: dict
|
||||
"""
|
||||
return accounts.get("activeAccountId") if isinstance(accounts, dict) else None
|
||||
|
||||
|
||||
get_active_account_id = get_current_account_id
|
||||
|
||||
|
||||
def get_current_account(accounts: dict) -> dict | None:
|
||||
"""
|
||||
Get current account from accountData.
|
||||
:param accounts:
|
||||
:return:
|
||||
account_dict: dict
|
||||
"""
|
||||
account_id = get_current_account_id(accounts)
|
||||
|
||||
return get_account(accounts, account_id) if account_id is not None else None
|
||||
|
||||
|
||||
get_active_account = get_current_account
|
||||
|
||||
|
||||
def set_current_account_id(accounts: dict, account_id: str | None) -> None:
|
||||
"""
|
||||
Set current account id
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
"""
|
||||
if account_id is not None and not is_account_exists(accounts, account_id):
|
||||
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||||
|
||||
accounts["activeAccountId"] = account_id
|
||||
|
||||
|
||||
set_active_account_id = set_current_account_id
|
||||
|
||||
|
||||
def add_account(accounts: dict, account: dict, *, overwrite: bool = False) -> tuple[dict, str]:
|
||||
"""
|
||||
Add an account to accountData.
|
||||
:param accounts:
|
||||
:param account:
|
||||
:param overwrite:
|
||||
:return:
|
||||
new_account_data: dict
|
||||
new_account_id: str
|
||||
"""
|
||||
if not is_account_valid(account):
|
||||
raise AccountException("Provided account data is not valid.")
|
||||
|
||||
account_id = account["accountID"]
|
||||
account_map = list_accounts(accounts)
|
||||
|
||||
if account_id in account_map and not overwrite:
|
||||
raise AccountException(f"Account id {account_id} already exists.")
|
||||
|
||||
account_map[account_id] = account
|
||||
|
||||
return accounts, account_id
|
||||
|
||||
|
||||
def create_account(account: Account, accounts: dict | None = None) -> tuple[dict, str]:
|
||||
"""
|
||||
Create new account
|
||||
:param account:
|
||||
:param accounts:
|
||||
:return:
|
||||
account_dict: dict
|
||||
new_account_id: str
|
||||
"""
|
||||
if accounts is None:
|
||||
accounts = get_account_sample()
|
||||
|
||||
account_data = create_account_data_from_object(account)
|
||||
_, account_id = add_account(accounts, account_data)
|
||||
|
||||
return account_data, account_id
|
||||
|
||||
|
||||
def update_account(accounts: dict, account_id: str, account_data: dict, fix_wrong: bool = False) -> tuple[dict, str]:
|
||||
"""
|
||||
Update target account data value
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:param account_data:
|
||||
:param fix_wrong:
|
||||
:return:
|
||||
updated_account_data: dict
|
||||
updated_account_id: str
|
||||
"""
|
||||
if not is_account_exists(accounts, account_id):
|
||||
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||||
|
||||
if not _check_account(account_data, account_id=account_id, fix_wrong=fix_wrong):
|
||||
raise AccountException("Provided account data is not valid.")
|
||||
|
||||
accounts["accounts"][account_id] = account_data
|
||||
|
||||
return accounts, account_id
|
||||
|
||||
|
||||
def remove_account(accounts: dict, account_id: str) -> None:
|
||||
"""
|
||||
Remove account from accountData.
|
||||
INFO: If the active account ID is same as the account that provided. Active account will be set to None.
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
"""
|
||||
account_map = list_accounts(accounts)
|
||||
|
||||
try:
|
||||
del account_map[account_id]
|
||||
except KeyError as exc:
|
||||
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.") from exc
|
||||
|
||||
if get_current_account_id(accounts) == account_id:
|
||||
accounts["activeAccountId"] = next(iter(account_map), None)
|
||||
|
||||
|
||||
def convert_account_data_to_object(accounts: dict, account_id: str) -> Account:
|
||||
"""
|
||||
Convert account data to Account
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
account_object: Account
|
||||
"""
|
||||
data = get_account(accounts, account_id)
|
||||
minecraft_profile = data.get("minecraftProfile", {})
|
||||
return Account(
|
||||
account_id=data.get("accountID", account_id),
|
||||
username=data.get("username", ""),
|
||||
access_token=data.get("accessToken", ""),
|
||||
refresh_token=data.get("refreshToken", ""),
|
||||
access_token_expires_at=data.get("accessTokenExpiresAt", ""),
|
||||
refresh_token_expires_at=data.get("refreshTokenExpiresAt", ""),
|
||||
minecraft_uuid=minecraft_profile.get("uuid", ""),
|
||||
minecraft_name=minecraft_profile.get("name", ""),
|
||||
avatar=data.get("avatar", ""),
|
||||
type=data.get("type", "msa"),
|
||||
user_properties=deepcopy(data.get("userProperties", [])),
|
||||
)
|
||||
|
||||
|
||||
def read_account_file(account_filepath: str | Path) -> dict:
|
||||
"""
|
||||
Read account file
|
||||
:param account_filepath:
|
||||
:return:
|
||||
accountData: dict
|
||||
"""
|
||||
path = Path(account_filepath)
|
||||
if not path.exists():
|
||||
raise AccountLoadException(f"Account file does not exist: {path}")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise AccountLoadException(f"Unable to read account file: {exc}") from exc
|
||||
if not is_accounts_valid(data):
|
||||
raise AccountLoadException("Account file contains invalid account data.")
|
||||
return data
|
||||
|
||||
|
||||
read_accounts_file = read_account_file
|
||||
|
||||
|
||||
def create_or_save_account_file(accounts: dict, account_filepath: str | Path,
|
||||
account_file_bak_filepath: str | Path | None = None,
|
||||
overwrite: bool = True,
|
||||
create_backup: bool = True) -> None:
|
||||
"""
|
||||
Create or save account file
|
||||
:param accounts:
|
||||
:param account_filepath:
|
||||
:param account_file_bak_filepath:
|
||||
:param overwrite:
|
||||
:param create_backup:
|
||||
:return:
|
||||
"""
|
||||
if not is_accounts_valid(accounts):
|
||||
raise AccountSaveException("Refusing to save invalid account data.")
|
||||
|
||||
path = Path(account_filepath)
|
||||
if path.exists() and not overwrite:
|
||||
raise AccountSaveException(f"Account file already exists: {path}")
|
||||
backup = (
|
||||
Path(account_file_bak_filepath)
|
||||
if account_file_bak_filepath is not None
|
||||
else path.with_suffix(path.suffix + ".bak")
|
||||
)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_text(
|
||||
json.dumps(accounts, indent=4, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
if create_backup and path.exists():
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
os.replace(path, backup)
|
||||
os.replace(temporary, path)
|
||||
except OSError as exc:
|
||||
if create_backup and backup.exists() and not path.exists():
|
||||
os.replace(backup, path)
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise AccountSaveException(f"Unable to save account file: {exc}") from exc
|
||||
|
||||
def get_account_avatar(accounts: dict, account_id: str) -> str | None:
|
||||
"""
|
||||
Get account avatar fron raw data
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:return:
|
||||
avatar_data_url: str | None
|
||||
"""
|
||||
account = get_account(accounts, account_id)
|
||||
|
||||
if "avatar" not in account:
|
||||
raise AccountKeyNotFoundException(f"Account {account_id} missing 'avatar' field")
|
||||
|
||||
return account.get("avatar", None)
|
||||
|
||||
def save_account_avatar(accounts: dict, account_id: str, avatar_raw: bytes | str) -> None:
|
||||
"""
|
||||
Save account avatar to account data
|
||||
:param accounts:
|
||||
:param account_id:
|
||||
:param avatar_raw: Set it to empty string as default
|
||||
:return:
|
||||
"""
|
||||
if not is_account_exists(accounts, account_id):
|
||||
raise AccountKeyNotFoundException(f"Specified account id {account_id} not found.")
|
||||
|
||||
if isinstance(avatar_raw, bytes):
|
||||
try:
|
||||
avatar_raw = bytes_to_data_url(avatar_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AccountSaveException("Invalid account avatar bytes.") from exc
|
||||
|
||||
if not is_valid_account_avatar(avatar_raw):
|
||||
raise AccountSaveException(f"Invalid account avatar: {avatar_raw}")
|
||||
|
||||
accounts["accounts"][account_id]["avatar"] = avatar_raw
|
||||
|
||||
return None
|
||||
|
||||
create_or_save_accounts_file = create_or_save_account_file
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Store all function for microsoft authentication
|
||||
|
||||
WARNING: This module is vibed! With code reviewed!
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
|
||||
from core_lib.common.exception import AuthenticationSessionException, AuthenticationConnectException, \
|
||||
AuthenticationException
|
||||
|
||||
MICROSOFT_DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||
XBOX_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"
|
||||
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"
|
||||
MINECRAFT_LOGIN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"
|
||||
MINECRAFT_ENTITLEMENTS_URL = "https://api.minecraftservices.com/entitlements/mcstore"
|
||||
MINECRAFT_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"
|
||||
|
||||
logger = logging.getLogger("Launcher.CoreLib")
|
||||
|
||||
def auth_exception_handler(request_handler: Callable, request_type: str = "without_token", stage: str = "authentication",):
|
||||
"""
|
||||
Handle authentication exceptions
|
||||
:param stage:
|
||||
:param request_handler:
|
||||
:param request_type: Set this will change exception type when an error occurs. ("without_token" for process that
|
||||
don't use token, "with_token" for process that use token. If with token, exception usually throw
|
||||
AuthenticationSessionException. If not, throw AuthenticationException instead)
|
||||
:return:
|
||||
"""
|
||||
exception_type = (
|
||||
AuthenticationSessionException
|
||||
if request_type == "with_token"
|
||||
else AuthenticationException
|
||||
)
|
||||
|
||||
try:
|
||||
return request_handler()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else "unknown"
|
||||
response_data = {}
|
||||
if e.response is not None:
|
||||
try:
|
||||
value = e.response.json()
|
||||
if isinstance(value, dict):
|
||||
response_data = value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
xerr = response_data.get("XErr")
|
||||
try:
|
||||
xerr = int(xerr) if xerr is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Below are the xsts error code suggestions. Why return that value (error is integer not string) ?
|
||||
# Maybe it makes international support more easily?
|
||||
xsts_suggestions = {
|
||||
2148916233: "This Microsoft account does not have an Xbox profile. Sign in at xbox.com and create one, then try again.",
|
||||
2148916235: "Xbox Live is not available for this account's country or region.",
|
||||
2148916236: "This account must complete adult verification on the Xbox website.",
|
||||
2148916237: "This account must complete age verification on the Xbox website.",
|
||||
2148916238: "This is a child account. An adult must add it to a Microsoft family and allow Xbox access.",
|
||||
}
|
||||
if xerr in xsts_suggestions:
|
||||
raise exception_type(
|
||||
f"{stage} failed with HTTP {status}, XErr {xerr}",
|
||||
user_message=f"{stage} failed (Xbox error {xerr}). {xsts_suggestions[xerr]}",
|
||||
) from e
|
||||
|
||||
server_message = response_data.get("Message") or response_data.get("error_description")
|
||||
# This suggestions can make user happy instead of seeing an HTTP error code
|
||||
suggestions = {
|
||||
400: "The authentication request or session was rejected.",
|
||||
401: "The login session is invalid or expired. Sign in again.",
|
||||
403: "This account is not allowed to complete this step. Check Xbox privacy settings and account restrictions."
|
||||
" Or check the client id has authorized to use Minecraft API.",
|
||||
429: "The authentication service received too many requests. Wait a moment and try again.",
|
||||
}
|
||||
|
||||
suggestion = suggestions.get(
|
||||
status,
|
||||
"The authentication service returned an unexpected response. Try again later.",
|
||||
)
|
||||
if server_message:
|
||||
suggestion = f"{suggestion} Server message: {server_message}"
|
||||
raise exception_type(f"{stage} failed with HTTP status {status}: {e}",
|
||||
user_message=f"{stage} failed (HTTP {status}). {suggestion}",
|
||||
) from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise AuthenticationConnectException(
|
||||
f"Unable to connect during {stage}: {e}",
|
||||
user_message=f"{stage} could not connect to the authentication server. Check your internet connection, proxy, and firewall.",
|
||||
) from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise AuthenticationConnectException(
|
||||
f"Request timed out during {stage}: {e}",
|
||||
user_message=f"{stage} timed out. Check your connection and try again later.",
|
||||
) from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise exception_type(
|
||||
f"Request error during {stage}: {e}",
|
||||
user_message=f"{stage} failed because the authentication request could not be completed: {e}",
|
||||
) from e
|
||||
except AuthenticationException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise exception_type(
|
||||
f"Unexpected error during {stage}: {traceback.format_exc()}",
|
||||
user_message=f"{stage} failed because the server returned invalid or unexpected data.",
|
||||
) from e
|
||||
|
||||
|
||||
def get_device_code(client_id: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get xbox live device code
|
||||
:param client_id:
|
||||
:return:
|
||||
device_code: str
|
||||
json_data: dict
|
||||
"""
|
||||
def request_handler():
|
||||
body = {
|
||||
"client_id": client_id,
|
||||
"scope": "XboxLive.signin offline_access",
|
||||
}
|
||||
# Microsoft OAuth endpoints require form-encoded request bodies.
|
||||
r = requests.post(MICROSOFT_DEVICE_CODE_URL, data=body)
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()
|
||||
|
||||
data = auth_exception_handler(request_handler, stage="Requesting a Microsoft sign-in code")
|
||||
|
||||
if not data.get("device_code"):
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing device_code",
|
||||
user_message="Microsoft did not return a sign-in code. Check the client ID or update the launcher.",
|
||||
)
|
||||
|
||||
return data["device_code"], data
|
||||
|
||||
def get_microsoft_token(client_id: str, device_code: str, interval: int, expires_in: float, cancelled=None) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Get microsoft token (with login wait)
|
||||
:param client_id:
|
||||
:param device_code:
|
||||
:param interval:
|
||||
:param expires_in:
|
||||
:param cancelled:
|
||||
:return:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(MICROSOFT_TOKEN_URL, data={
|
||||
"client_id": client_id,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
})
|
||||
|
||||
return r.status_code, r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, str, dict]:
|
||||
access_token = result.get("access_token")
|
||||
refresh_token = result.get("refresh_token")
|
||||
if not access_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing access_token",
|
||||
user_message="Microsoft approved the sign-in but did not return an access token. Please sign in again.",
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing refresh_token",
|
||||
user_message="Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.",
|
||||
)
|
||||
|
||||
return access_token, refresh_token, result
|
||||
|
||||
deadline = time.monotonic() + expires_in
|
||||
while time.monotonic() < deadline:
|
||||
# cancel callback
|
||||
if cancelled and cancelled():
|
||||
raise InterruptedError("Login cancelled.")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
status, data = auth_exception_handler(_request_handler, stage="Waiting for Microsoft sign-in approval")
|
||||
|
||||
if status == 200:
|
||||
return verify_data(data)
|
||||
error = data.get("error")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval += 5
|
||||
continue
|
||||
|
||||
description = data.get("error_description") or data.get("error") or "No details were provided"
|
||||
raise AuthenticationException(
|
||||
f"Microsoft token request failed with HTTP {status}: {description}",
|
||||
user_message=f"Microsoft sign-in was rejected (HTTP {status}): {description}",
|
||||
)
|
||||
|
||||
raise TimeoutError("Microsoft sign-in code expired before login was completed. Start the sign-in process again.")
|
||||
|
||||
def get_xbox_token_and_user_hash(access_token: str) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Get xbox live token and user hash
|
||||
:param access_token: Microsoft account access token
|
||||
:return:
|
||||
xbox_token: str
|
||||
user_hash: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(XBOX_AUTH_URL, json={
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": f"d={access_token}",
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT",
|
||||
}, headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, str, dict]:
|
||||
xbox_token = result.get("Token")
|
||||
user_hash = None
|
||||
|
||||
try:
|
||||
user_hash = result.get("DisplayClaims", {}).get("xui", {})[0].get("uhs")
|
||||
except Exception as e:
|
||||
logger.error("Unable to get user hash from data {}: {}".format(result, e))
|
||||
|
||||
if not xbox_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing xbox_token",
|
||||
user_message="Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.",
|
||||
)
|
||||
|
||||
if not user_hash:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing user_hash",
|
||||
user_message="Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.",
|
||||
)
|
||||
|
||||
return xbox_token, user_hash, result
|
||||
|
||||
data = auth_exception_handler(_request_handler, stage="Signing in to Xbox Live")
|
||||
return verify_data(data)
|
||||
|
||||
def get_xsts_token(xbox_token: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get xsts token
|
||||
:param xbox_token:
|
||||
:return:
|
||||
xsts_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(XSTS_AUTH_URL, json={
|
||||
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbox_token]},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT",
|
||||
}, headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, dict]:
|
||||
xsts_token = result.get("Token")
|
||||
|
||||
if not xsts_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing xsts_token",
|
||||
user_message="Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.",
|
||||
)
|
||||
|
||||
return xsts_token, result
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Authorizing the Xbox session for Minecraft",
|
||||
)
|
||||
|
||||
return verify_data(data)
|
||||
|
||||
def get_minecraft_access_token(user_hash: str, xsts_token: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Get minecraft access token
|
||||
:param user_hash: Xbox live user hash
|
||||
:param xsts_token: XSTS token
|
||||
Above param can get from get_xbox_token_and_user_hash and get_xsts_token
|
||||
:return:
|
||||
access_token: str
|
||||
json_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
r = requests.post(MINECRAFT_LOGIN_URL, json={"identityToken": f"XBL3.0 x={user_hash};{xsts_token}"},
|
||||
headers={"Accept": "application/json"})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def verify_data(result: dict) -> tuple[str, dict]:
|
||||
access_token = result.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise AuthenticationException(
|
||||
"Authentication response missing access token",
|
||||
user_message="Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.",
|
||||
)
|
||||
|
||||
return access_token, result
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Signing in to Minecraft services",
|
||||
)
|
||||
|
||||
return verify_data(data)
|
||||
|
||||
|
||||
def get_account_entitlements(minecraft_access_token: str) -> dict:
|
||||
"""
|
||||
Get minecraft account entitlements
|
||||
:param minecraft_access_token:
|
||||
:return:
|
||||
"""
|
||||
def _request_handler():
|
||||
response = requests.get(
|
||||
MINECRAFT_ENTITLEMENTS_URL,
|
||||
headers={"Authorization": f"Bearer {minecraft_access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Checking Minecraft ownership",
|
||||
)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
|
||||
raise AuthenticationSessionException(
|
||||
"Minecraft entitlement response is missing the items list",
|
||||
user_message="Minecraft services returned an invalid ownership response. Please sign in again or try later.",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_minecraft_profile(minecraft_access_token: str) -> dict:
|
||||
"""
|
||||
Get minecraft profile
|
||||
:param minecraft_access_token:
|
||||
:return:
|
||||
profile_data: dict
|
||||
"""
|
||||
def _request_handler():
|
||||
response = requests.get(
|
||||
MINECRAFT_PROFILE_URL,
|
||||
headers={"Authorization": f"Bearer {minecraft_access_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
data = auth_exception_handler(
|
||||
_request_handler,
|
||||
request_type="with_token",
|
||||
stage="Loading the Minecraft profile",
|
||||
)
|
||||
if not isinstance(data, dict) or not data.get("id") or not data.get("name"):
|
||||
raise AuthenticationSessionException(
|
||||
"Minecraft profile response is missing id or name",
|
||||
user_message="Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def has_minecraft_license(entitlements: dict) -> bool:
|
||||
"""
|
||||
Check if provided account entitlements have minecraft license
|
||||
:param entitlements:
|
||||
:return:
|
||||
found: bool
|
||||
"""
|
||||
if not isinstance(entitlements, dict):
|
||||
raise AuthenticationException(
|
||||
f"Expected Minecraft entitlements to be a dictionary, got {type(entitlements).__name__}",
|
||||
user_message="Unable to verify Minecraft ownership because the ownership response is invalid.",
|
||||
)
|
||||
|
||||
items = entitlements.get("items")
|
||||
if not isinstance(items, list):
|
||||
raise AuthenticationException(
|
||||
"Minecraft entitlement response is missing a valid items list",
|
||||
user_message="Unable to verify Minecraft ownership because the ownership list is missing or invalid.",
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(item, dict)
|
||||
and item.get("name") in {"game_minecraft", "product_minecraft"}
|
||||
for item in items
|
||||
)
|
||||
|
||||
def refresh_microsoft_token(client_id: str, refresh_token: str) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Use existing microsoft refresh token to get new (or extend the expires time) refresh token
|
||||
Returns:
|
||||
microsoft_access_token,
|
||||
new_refresh_token,
|
||||
response_data
|
||||
"""
|
||||
|
||||
def request_handler():
|
||||
response = requests.post(
|
||||
MICROSOFT_TOKEN_URL,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "XboxLive.signin offline_access",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
result = auth_exception_handler(
|
||||
request_handler,
|
||||
request_type="with_token",
|
||||
stage="Refreshing Microsoft credentials",
|
||||
)
|
||||
|
||||
access_token = result.get("access_token")
|
||||
new_refresh_token = result.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
raise AuthenticationSessionException(
|
||||
"Refresh response missing access_token",
|
||||
user_message=(
|
||||
"Microsoft could not refresh this account. "
|
||||
"Please sign in again."
|
||||
),
|
||||
)
|
||||
|
||||
# Return original refresh token if new token is not show up in response
|
||||
return (
|
||||
access_token,
|
||||
new_refresh_token or refresh_token,
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \
|
||||
ProfileLoadException
|
||||
|
||||
@@ -434,6 +436,14 @@ def duplicate_profile(profiles: dict, profile_id: str, new_name: str | None=None
|
||||
)
|
||||
|
||||
def convert_profile_data_to_object(profiles: dict, profile_id: str) -> Profile:
|
||||
"""
|
||||
Convert profiles data to object
|
||||
:param profiles:
|
||||
:param profile_id:
|
||||
:return:
|
||||
profiles: dict
|
||||
profile_id: str
|
||||
"""
|
||||
profile = get_profile(profiles, profile_id)
|
||||
|
||||
created: str | datetime.datetime = profile.get("created", datetime.datetime.now())
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Contains some function that deal with 3rd party api
|
||||
"""
|
||||
import requests
|
||||
|
||||
from core_lib.common.exception import ThirdPartyAPIException
|
||||
|
||||
MC_HEADS_HEAD_ENDPOINT = "https://api.mcheads.org/head/{username}/{size}"
|
||||
|
||||
def get_minecraft_head_url(username: str, size: int=256) -> str:
|
||||
if not 256 >= size >= 32:
|
||||
raise ValueError("size must be between 256 and 32")
|
||||
|
||||
return MC_HEADS_HEAD_ENDPOINT.format(username=username, size=size)
|
||||
|
||||
def get_minecraft_head(username: str, size: int=256, timeout: float=10.0) -> bytes:
|
||||
"""
|
||||
Get minecraft head
|
||||
:param username:
|
||||
:param size:
|
||||
:return:
|
||||
head_data: bytes
|
||||
"""
|
||||
url = get_minecraft_head_url(username, size)
|
||||
|
||||
try:
|
||||
r = requests.get(url, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status_code = e.response.status_code
|
||||
|
||||
if status_code == 404:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP 404 when fetching api {}. Did the provider still running this service ?".format(MC_HEADS_HEAD_ENDPOINT),
|
||||
user_message="Unable to fetch third party API. Provider may no longer exist. Try updating the launcher."
|
||||
)
|
||||
elif status_code == 500:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP 500 when fetching api {}. Did the head size is correct?".format(MC_HEADS_HEAD_ENDPOINT),
|
||||
user_message="Unable to fetch third party API. Try updating the launcher.",
|
||||
)
|
||||
else:
|
||||
raise ThirdPartyAPIException(
|
||||
"Got HTTP {} unhandled error. Result: {}".format(status_code, e.response.text),
|
||||
user_message="Unable to fetch third party API. Unknown HTTP Error: {}".format(status_code),
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ThirdPartyAPIException(
|
||||
"An error occurred while fetching third party API: {}".format(e),
|
||||
user_message="Unable to fetch third party API. Try updating the launcher.",
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ def get_version_manifest(manifest_url=MOJANG_VERSION_MANIFEST_V2, timeout=10) ->
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4)))
|
||||
# logger.debug("VersionManifest: {}".format(json.dumps(data, indent=4)))
|
||||
return data
|
||||
except ValueError as e:
|
||||
raise VersionManifestFetchException(
|
||||
@@ -180,10 +180,10 @@ def get_all_versions(version_manifest: dict, is_snapshot=False, include_all_type
|
||||
filtered = []
|
||||
|
||||
for ver in all_vers:
|
||||
ver_id = ver.get("id", None)
|
||||
# ver_id = ver.get("id", None)
|
||||
ver_type = ver.get("type")
|
||||
|
||||
logger.debug("Ver: {}, Type: {}".format(ver_id, ver_type))
|
||||
# logger.debug("Ver: {}, Type: {}".format(ver_id, ver_type))
|
||||
|
||||
if ver_type == target_type or include_all_type:
|
||||
filtered.append(ver)
|
||||
|
||||
+25
-3
@@ -1,11 +1,12 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt
|
||||
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor
|
||||
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget
|
||||
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor, QImage
|
||||
from PySide6.QtWidgets import QMainWindow, QApplication, QWidget, QDialog
|
||||
|
||||
|
||||
def move_window_to_center(window: QMainWindow):
|
||||
def move_window_to_center(window: QMainWindow | QDialog) -> None:
|
||||
main_screen = QApplication.primaryScreen()
|
||||
center = QScreen.availableGeometry(main_screen).center()
|
||||
geometry = window.frameGeometry()
|
||||
@@ -102,4 +103,25 @@ def add_icon_padding(icon: QIcon, icon_size=32, padding=6) -> QIcon:
|
||||
|
||||
return QIcon(canvas)
|
||||
|
||||
def data_url_to_qt_image(data_url: str) -> QImage:
|
||||
try:
|
||||
header, encoded_data = data_url.split(",", 1)
|
||||
except ValueError:
|
||||
raise ValueError("Not a valid data url")
|
||||
|
||||
if ";base64" not in header:
|
||||
raise ValueError("Unsupported data url. Supported data urls are base64")
|
||||
|
||||
try:
|
||||
image_data = base64.b64decode(encoded_data, validate=True)
|
||||
except ValueError as error:
|
||||
raise ValueError("Invalid image data") from error
|
||||
|
||||
image = QImage.fromData(image_data)
|
||||
|
||||
if image.isNull():
|
||||
raise ValueError("Qt does not support this image")
|
||||
|
||||
return image
|
||||
|
||||
# ======================== AI generated END ========================
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
+1194
File diff suppressed because it is too large
Load Diff
+37
-9
@@ -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 = ["<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
|
||||
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)
|
||||
|
||||
+294
-46
@@ -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)
|
||||
|
||||
+47
-43
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user