2026-08-03 00:16:12 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import webbrowser
|
2026-07-14 22:22:58 +08:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
from PySide6.QtCore import QSize, Qt, Signal, QTimer
|
2026-08-03 00:30:02 +08:00
|
|
|
from PySide6.QtGui import QPixmap, QFontDatabase, QIcon
|
2026-08-03 00:16:12 +08:00
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
QHBoxLayout,
|
|
|
|
|
QLabel,
|
|
|
|
|
QListWidget,
|
|
|
|
|
QListWidgetItem,
|
|
|
|
|
QMenu,
|
|
|
|
|
QMessageBox,
|
|
|
|
|
QPushButton,
|
|
|
|
|
QVBoxLayout,
|
2026-08-03 00:30:02 +08:00
|
|
|
QWidget, QDialog, QLineEdit, QApplication, QToolButton,
|
2026-08-03 00:16:12 +08:00
|
|
|
)
|
|
|
|
|
|
2026-08-03 20:55:37 +08:00
|
|
|
from constant import MICROSOFT_VERIFY_ENDPOINT
|
2026-08-03 00:16:12 +08:00
|
|
|
|
|
|
|
|
from core_lib.qt.hack import move_window_to_center
|
|
|
|
|
from manager.account import AccountManager, AccountSummary
|
2026-08-03 20:55:37 +08:00
|
|
|
from manager.settings_models import AccountSettings
|
2026-07-14 22:22:58 +08:00
|
|
|
|
|
|
|
|
|
2026-07-23 00:47:44 +08:00
|
|
|
class AccountWidget(QWidget):
|
2026-08-03 00:16:12 +08:00
|
|
|
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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Layout
|
2026-07-23 00:47:44 +08:00
|
|
|
self.layout = QHBoxLayout(self)
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Widgets
|
2026-07-23 00:47:44 +08:00
|
|
|
self.head_icon_label = QLabel()
|
|
|
|
|
self.head_icon_label.setObjectName("HeadIcon")
|
2026-08-03 00:16:12 +08:00
|
|
|
self.head_icon_label.setFixedSize(account_head.size())
|
2026-07-23 00:47:44 +08:00
|
|
|
self.head_icon_label.setPixmap(account_head)
|
|
|
|
|
|
|
|
|
|
self.account_name = QLabel(account_name)
|
|
|
|
|
self.status_label = QLabel(login_status)
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Bind widgets
|
2026-07-23 00:47:44 +08:00
|
|
|
self.layout.addWidget(self.head_icon_label)
|
|
|
|
|
self.layout.addWidget(self.account_name)
|
|
|
|
|
self.layout.addStretch()
|
|
|
|
|
self.layout.addWidget(self.status_label)
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Callback
|
2026-07-23 00:47:44 +08:00
|
|
|
self.delete_account_callback = delete_account_callback
|
|
|
|
|
self.check_account_callback = check_account_callback
|
|
|
|
|
self.switch_account_callback = switch_account_callback
|
|
|
|
|
|
|
|
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
|
|
|
self.customContextMenuRequested.connect(self.show_right_click_menu)
|
|
|
|
|
|
|
|
|
|
def show_right_click_menu(self, position):
|
2026-08-03 00:16:12 +08:00
|
|
|
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))
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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()
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Layout
|
|
|
|
|
self.layout = QVBoxLayout(self)
|
|
|
|
|
self.layout.setContentsMargins(24, 22, 24, 22)
|
|
|
|
|
self.layout.setSpacing(14)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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")
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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))
|
2026-08-01 00:47:41 +08:00
|
|
|
|
2026-07-14 22:22:58 +08:00
|
|
|
class AccountPage(QWidget):
|
2026-08-03 00:16:12 +08:00
|
|
|
account_authenticated = Signal(dict)
|
|
|
|
|
|
2026-07-14 22:22:58 +08:00
|
|
|
def __init__(self, app, parent=None):
|
2026-08-03 00:16:12 +08:00
|
|
|
super().__init__(parent)
|
2026-07-14 22:22:58 +08:00
|
|
|
self.app = app
|
2026-08-03 00:16:12 +08:00
|
|
|
self.widget_manager = app.widget_manager
|
|
|
|
|
self.account_manager: AccountManager = app.account_manager
|
2026-07-14 22:22:58 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
self.login_dialog = None
|
2026-07-14 22:22:58 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Layout
|
|
|
|
|
main_layout = QVBoxLayout()
|
|
|
|
|
right_layout = QVBoxLayout()
|
|
|
|
|
layout = QHBoxLayout(self)
|
2026-07-14 22:22:58 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# Widgets
|
2026-07-23 00:47:44 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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()
|
2026-07-14 22:22:58 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
layout.addLayout(main_layout, 1)
|
|
|
|
|
layout.addLayout(right_layout)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:30:02 +08:00
|
|
|
self.related_button : QToolButton = None
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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()
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:30:02 +08:00
|
|
|
def set_related_button(self, button: QToolButton):
|
|
|
|
|
self.related_button = button
|
|
|
|
|
self._update_related_button(None)
|
|
|
|
|
|
|
|
|
|
def _update_related_button(self, account_id: str | None):
|
|
|
|
|
"""
|
|
|
|
|
Update related button's icon to current (active) account avatar
|
|
|
|
|
:param account_id:
|
|
|
|
|
:return:
|
|
|
|
|
"""
|
|
|
|
|
if self.related_button is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if account_id is None:
|
|
|
|
|
account_id = self.account_manager.get_current_account_id()
|
|
|
|
|
|
|
|
|
|
if not self.account_manager.is_current_account(account_id):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
account = self.account_manager.get_account(account_id)
|
|
|
|
|
if account is None:
|
|
|
|
|
self.related_button.setIcon(
|
|
|
|
|
self.app.get_icon(Path(self.app.icon_dir, "head.png"), True)
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
pixmap = self.account_manager.get_head_pixmap(
|
|
|
|
|
account.avatar,
|
|
|
|
|
QSize(32, 32),
|
|
|
|
|
)
|
|
|
|
|
# Apply avatar to open account page button
|
|
|
|
|
self.related_button.setIcon(QIcon(pixmap))
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
def _render_accounts(self):
|
|
|
|
|
self.account_list.clear()
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
current_id = self.account_manager.get_current_account_id()
|
|
|
|
|
accounts = self.account_manager.list_accounts()
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
# 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)
|
2026-07-23 00:47:44 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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):
|
2026-08-03 00:30:02 +08:00
|
|
|
pixmap = self.account_manager.get_head_pixmap(account.avatar, QSize(32, 32))
|
2026-08-03 00:16:12 +08:00
|
|
|
widget.head_icon_label.setPixmap(
|
2026-08-03 00:30:02 +08:00
|
|
|
pixmap,
|
2026-08-03 00:16:12 +08:00
|
|
|
)
|
2026-08-03 00:30:02 +08:00
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
break
|
|
|
|
|
|
2026-08-03 00:30:02 +08:00
|
|
|
self._update_related_button(account_id)
|
|
|
|
|
|
2026-08-03 00:16:12 +08:00
|
|
|
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()
|
|
|
|
|
|
2026-08-03 20:55:37 +08:00
|
|
|
account_settings = self.app.settings_manager.get("account", AccountSettings)
|
|
|
|
|
self.account_manager.start_login(str(account_settings.client_id))
|
2026-08-03 00:16:12 +08:00
|
|
|
|
|
|
|
|
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)
|