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
+294 -46
View File
@@ -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
View File
@@ -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")
+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)
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
)