Add multiple languages (i18n) support.

Now you can add a version in profile manage page.

Add install java button and dialog.
This commit is contained in:
wei
2026-08-04 17:39:38 +08:00
parent 6862f3f4b3
commit f6bf8f5c8b
16 changed files with 2166 additions and 272 deletions
+30 -23
View File
@@ -25,9 +25,10 @@ from manager.settings_models import AccountSettings
class AccountWidget(QWidget):
def __init__(self, account_name: str, account_head: QPixmap, login_status: str, parent=None,
def __init__(self, app, 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)
self.app = app
# Layout
self.layout = QHBoxLayout(self)
@@ -57,10 +58,10 @@ class AccountWidget(QWidget):
def show_right_click_menu(self, position):
menu = QMenu(self)
switch_action = menu.addAction("Switch to this account")
check_action = menu.addAction("Check login status")
switch_action = menu.addAction(self.app.tr_text("Switch to this account"))
check_action = menu.addAction(self.app.tr_text("Check login status"))
menu.addSeparator()
delete_action = menu.addAction("Delete account")
delete_action = menu.addAction(self.app.tr_text("Delete account"))
selected = menu.exec(self.mapToGlobal(position))
if selected == delete_action and callable(self.delete_account_callback):
@@ -74,7 +75,7 @@ class LoginDialog(QDialog):
def __init__(self, app, parent=None, code="", error_message=""):
super().__init__(parent)
self.app = app
self.setWindowTitle("Microsoft Login")
self.setWindowTitle(self.app.tr_text("Microsoft Login"))
self.setMinimumWidth(440)
self.setMinimumHeight(380)
self.resize(440, 420)
@@ -86,9 +87,9 @@ class LoginDialog(QDialog):
self.layout.setSpacing(14)
# Top widgets
self.title_label = QLabel("Login Minecraft Account")
self.title_label = QLabel(self.app.tr_text("Login Minecraft Account"))
self.title_label.setObjectName("dialogTitle")
self.help_label = QLabel("Copy the code below, then continue in your browser.")
self.help_label = QLabel(self.app.tr_text("Copy the code below, then continue in your browser."))
self.help_label.setObjectName("helpText")
self.help_label.setWordWrap(True)
@@ -106,9 +107,9 @@ class LoginDialog(QDialog):
self.error_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
# Button
self.copy_code_button = QPushButton("Copy code")
self.copy_code_button = QPushButton(self.app.tr_text("Copy code"))
self.copy_code_button.setObjectName("copyCodeButton")
self.open_browser_button = QPushButton("Open browser")
self.open_browser_button = QPushButton(self.app.tr_text("Open browser"))
self.open_browser_button.setObjectName("openBrowserButton")
self.center_layout = QVBoxLayout()
@@ -142,12 +143,12 @@ class LoginDialog(QDialog):
def copy_code(self):
QApplication.clipboard().setText(self.code)
self.copy_code_button.setText("Copied!")
self.copy_code_button.setText(self.app.tr_text("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.setText(self.app.tr_text("Copy code"))
self.copy_code_button.setEnabled(True)
def set_error_message(self, message):
@@ -173,9 +174,9 @@ class AccountPage(QWidget):
# 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.add_account_button = QPushButton(self.app.tr_text("Add Account"))
self.refresh_button = QPushButton(self.app.tr_text("Refresh all"))
self.delete_all_button = QPushButton(self.app.tr_text("Delete All"))
# Button click handle
self.add_account_button.clicked.connect(self.start_login_page)
@@ -244,7 +245,7 @@ class AccountPage(QWidget):
# 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 = QListWidgetItem(self.app.tr_text("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)
@@ -258,9 +259,10 @@ class AccountPage(QWidget):
head_size = QSize(32, 32)
widget = AccountWidget(
self.app,
account.display_name,
self.account_manager.get_head_pixmap(account.avatar, head_size),
"Current" if is_current else "Available",
self.app.tr_text("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),
@@ -310,9 +312,14 @@ class AccountPage(QWidget):
QMessageBox.information(
self,
"Account status",
f"{account.display_name} — Minecraft profile "
f"{'available' if account.minecraft_uuid else 'not available'}",
self.app.tr_text("Account status"),
self.app.tr_text(
"{account} — Minecraft profile {status}",
account=account.display_name,
status=self.app.tr_text(
"available" if account.minecraft_uuid else "not available"
),
),
)
def _switch_account(self, account_id: str):
@@ -336,7 +343,7 @@ class AccountPage(QWidget):
# 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.help_label.setText(self.app.tr_text("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)
@@ -351,7 +358,7 @@ class AccountPage(QWidget):
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."
device.get("message") or self.app.tr_text("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)
@@ -378,11 +385,11 @@ class AccountPage(QWidget):
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.help_label.setText(self.app.tr_text("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)
QMessageBox.critical(self, self.app.tr_text("Microsoft sign-in failed"), message)
def _login_finished(self):
self.add_account_button.setEnabled(True)
+315
View File
@@ -0,0 +1,315 @@
import threading
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QPushButton,
QVBoxLayout,
)
from core_lib.game.mod import fabric
from core_lib.game.mod.mod_core import find_useful_part_in_mod_version_manifest
from core_lib.game.version_info import get_available_version_types
class AddModdedVersionDialog(QDialog):
"""
Create a local mod-loader manifest without creating a profile.
This dialog is vibed.
"""
version_created = Signal(str)
class WorkerSignals(QObject):
manifest_loaded = Signal(object, object)
loader_versions_loaded = Signal(str, object, object)
version_installed = Signal(object, object)
def __init__(self, app, parent=None):
super().__init__(parent)
self.app = app
self.manifest_manager = app.game_manager.manifest
self.root_manifest: dict = {}
self.worker_signals = self.WorkerSignals(self)
self.setWindowTitle(self.app.tr_text("Add Modded Version"))
self.setMinimumWidth(480)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(14)
title = QLabel(self.app.tr_text("Add Modded Version"))
title.setObjectName("dialogTitle")
description = QLabel(self.app.tr_text(
"Create a local mod-loader version manifest without creating a profile."
))
description.setWordWrap(True)
form = QFormLayout()
form.setVerticalSpacing(10)
self.loader_dropdown = QComboBox()
self.loader_dropdown.setObjectName("Dropdown")
self.loader_dropdown.addItem(self.app.tr_text("Fabric"), "fabric")
self.minecraft_type_dropdown = QComboBox()
self.minecraft_type_dropdown.setObjectName("Dropdown")
self.minecraft_version_dropdown = QComboBox()
self.minecraft_version_dropdown.setObjectName("Dropdown")
self.loader_version_dropdown = QComboBox()
self.loader_version_dropdown.setObjectName("Dropdown")
form.addRow(self.app.tr_text("Mod loader"), self.loader_dropdown)
form.addRow(self.app.tr_text("Minecraft type"), self.minecraft_type_dropdown)
form.addRow(self.app.tr_text("Minecraft version"), self.minecraft_version_dropdown)
form.addRow(self.app.tr_text("Loader version"), self.loader_version_dropdown)
self.status_label = QLabel()
self.status_label.setWordWrap(True)
buttons = QHBoxLayout()
self.refresh_button = QPushButton(self.app.tr_text("Refresh"))
self.cancel_button = QPushButton(self.app.tr_text("Cancel"))
self.add_button = QPushButton(self.app.tr_text("Add Version"))
self.add_button.setObjectName("saveProfileButton")
buttons.addWidget(self.refresh_button)
buttons.addStretch()
buttons.addWidget(self.cancel_button)
buttons.addWidget(self.add_button)
root.addWidget(title)
root.addWidget(description)
root.addLayout(form)
root.addWidget(self.status_label)
root.addLayout(buttons)
self.minecraft_type_dropdown.currentIndexChanged.connect(
self._update_minecraft_versions
)
self.minecraft_version_dropdown.currentIndexChanged.connect(
self._start_load_loader_versions
)
self.refresh_button.clicked.connect(self.load_versions)
self.cancel_button.clicked.connect(self.reject)
self.add_button.clicked.connect(self.install_selected_version)
self.worker_signals.manifest_loaded.connect(self._manifest_loaded)
self.worker_signals.loader_versions_loaded.connect(
self._loader_versions_loaded
)
self.worker_signals.version_installed.connect(self._version_installed)
self.load_versions()
@staticmethod
def _error_text(error: Exception) -> str:
return str(getattr(error, "user_message", None) or error)
def _set_loading(self, loading: bool, status: str = "") -> None:
self.refresh_button.setEnabled(not loading)
self.add_button.setEnabled(not loading)
self.status_label.setText(status)
def load_versions(self) -> None:
self._set_loading(True, self.app.tr_text("Loading Minecraft versions..."))
self.minecraft_type_dropdown.setEnabled(False)
self.minecraft_version_dropdown.setEnabled(False)
self.loader_version_dropdown.setEnabled(False)
def worker():
try:
manifest = self.manifest_manager.get_cached_version_manifest()
if not manifest:
raise RuntimeError("Unable to load the Minecraft version manifest.")
self.worker_signals.manifest_loaded.emit(manifest, None)
except Exception as error:
self.worker_signals.manifest_loaded.emit(None, error)
threading.Thread(target=worker, daemon=True).start()
def _manifest_loaded(self, manifest, error) -> None:
if error is not None:
self._set_loading(False, self.app.tr_text(
"Unable to load versions: {error}", error=self._error_text(error)
))
return
self.root_manifest = manifest
try:
types = get_available_version_types(manifest)
except Exception as exc:
self._set_loading(False, self.app.tr_text(
"Unable to read version types: {error}", error=self._error_text(exc)
))
return
types = [item for item in types if isinstance(item, str) and item]
if "release" in types:
types.remove("release")
types.insert(0, "release")
self.minecraft_type_dropdown.blockSignals(True)
self.minecraft_type_dropdown.clear()
self.minecraft_type_dropdown.addItems(types)
self.minecraft_type_dropdown.blockSignals(False)
self.minecraft_type_dropdown.setEnabled(True)
self.minecraft_version_dropdown.setEnabled(True)
self._update_minecraft_versions()
def _update_minecraft_versions(self, _index: int = -1) -> None:
version_type = self.minecraft_type_dropdown.currentText()
self.minecraft_version_dropdown.blockSignals(True)
self.minecraft_version_dropdown.clear()
for version in self.root_manifest.get("versions", []):
if version.get("type") != version_type:
continue
version_id = version.get("id")
if version_id:
self.minecraft_version_dropdown.addItem(version_id, version_id)
self.minecraft_version_dropdown.blockSignals(False)
self._start_load_loader_versions()
def _start_load_loader_versions(self, _index: int = -1) -> None:
minecraft_version = self.minecraft_version_dropdown.currentData()
self.loader_version_dropdown.clear()
self.loader_version_dropdown.setEnabled(False)
if not minecraft_version:
self._set_loading(False, self.app.tr_text("No Minecraft version is available for this type."))
return
self._set_loading(True, self.app.tr_text(
"Loading Fabric versions for {version}...", version=minecraft_version
))
def worker():
try:
loaders = fabric.get_version_support_loader_list(minecraft_version)
self.worker_signals.loader_versions_loaded.emit(
minecraft_version, loaders, None
)
except Exception as error:
self.worker_signals.loader_versions_loaded.emit(
minecraft_version, None, error
)
threading.Thread(target=worker, daemon=True).start()
def _loader_versions_loaded(self, minecraft_version, loaders, error) -> None:
# Ignore an obsolete response after the user selected another version.
if minecraft_version != self.minecraft_version_dropdown.currentData():
return
if error is not None:
self._set_loading(
False,
self.app.tr_text(
"Unable to load Fabric versions: {error}",
error=self._error_text(error),
),
)
return
self.loader_version_dropdown.clear()
for loader in loaders:
loader_version = loader.get("version")
if not loader_version:
continue
display_name = loader_version
if loader.get("stable") is True:
display_name += " (stable)"
self.loader_version_dropdown.addItem(display_name, loader)
self.loader_version_dropdown.setEnabled(
self.loader_version_dropdown.count() > 0
)
self._set_loading(False, self.app.tr_text("Select a loader version to continue."))
def install_selected_version(self) -> None:
minecraft_version = self.minecraft_version_dropdown.currentData()
loader_data = self.loader_version_dropdown.currentData()
if not minecraft_version or not isinstance(loader_data, dict):
self.status_label.setText(self.app.tr_text("Select a Minecraft and loader version."))
return
loader_version = loader_data.get("version")
if not loader_version:
self.status_label.setText(self.app.tr_text("The selected loader version is invalid."))
return
self._set_loading(True, self.app.tr_text("Creating the Fabric version manifest..."))
self.loader_dropdown.setEnabled(False)
self.minecraft_type_dropdown.setEnabled(False)
self.minecraft_version_dropdown.setEnabled(False)
self.loader_version_dropdown.setEnabled(False)
def worker():
try:
# Cache the parent manifest as well, so the new modded version
# can be resolved immediately when it is selected for launch.
parent = self.manifest_manager.get_cached_version_manifest(
minecraft_version
)
if not parent:
raise RuntimeError(
f"Unable to cache Minecraft {minecraft_version}."
)
version_id = fabric.get_full_version_string(
version=minecraft_version,
loader_version=loader_version,
)
def fetch_manifest(_version_id: str):
return (
fabric.get_loader_manifest_from_loader_data(
minecraft_version, loader_data
),
None,
)
manifest = self.manifest_manager.get_cached_custom_version_manifest(
version_id,
fetch_manifest_handler=fetch_manifest,
)
if not manifest:
raise RuntimeError("Unable to create the Fabric version manifest.")
loader_info = find_useful_part_in_mod_version_manifest(manifest)
if loader_info["id"] != version_id:
raise ValueError(
f"Manifest ID mismatch: expected {version_id}, "
f"got {loader_info['id']}."
)
if loader_info["inheritsFrom"] != minecraft_version:
raise ValueError(
"The created manifest inherits from an unexpected "
"Minecraft version."
)
self.worker_signals.version_installed.emit(version_id, None)
except Exception as error:
self.worker_signals.version_installed.emit(None, error)
threading.Thread(target=worker, daemon=True).start()
def _version_installed(self, version_id, error) -> None:
if error is not None:
self.loader_dropdown.setEnabled(True)
self.minecraft_type_dropdown.setEnabled(True)
self.minecraft_version_dropdown.setEnabled(True)
self.loader_version_dropdown.setEnabled(True)
self._set_loading(False, self.app.tr_text(
"Unable to add version: {error}", error=self._error_text(error)
))
return
self.status_label.setText(self.app.tr_text(
"Added version {version_id}.", version_id=version_id
))
self.version_created.emit(version_id)
self.accept()
+74 -38
View File
@@ -13,6 +13,7 @@ from core_lib.game.version_info import get_all_versions, get_available_version_t
from manager.game import ManifestManager, GameManager
from manager.profile import CreateProfileRequest
from manager.widgets import SignalTransferItem, WidgetManager
from pages.install_java_dialog import InstallJavaDialog
class CreateProfilePage(QWidget):
def __init__(self, app, parent=None):
@@ -33,7 +34,7 @@ class CreateProfilePage(QWidget):
self.operate_layout = QHBoxLayout()
# Some items
self.version_type_label = QLabel("Version Type")
self.version_type_label = QLabel(self.app.tr_text("Version Type"))
self.version_type_dropdown = QComboBox()
self.version_type_dropdown.setObjectName("Dropdown")
@@ -42,7 +43,7 @@ class CreateProfilePage(QWidget):
icon = self.app.get_icon(Path(self.app.icon_dir, "mod_loader_temp.png"))
pixmap = icon.pixmap(QSize(100, 100))
self.mod_loader_icon_label.setPixmap(pixmap)
self.mod_loader_label = QLabel("Mod Loader")
self.mod_loader_label = QLabel(self.app.tr_text("Mod Loader"))
self.mod_loader_dropdown = QComboBox()
self.mod_loader_dropdown.setObjectName("Dropdown")
@@ -50,9 +51,7 @@ class CreateProfilePage(QWidget):
self.mod_config_layout = QHBoxLayout()
self.mod_config_fields_layout = QVBoxLayout()
self.mod_loader_version_label = QLabel(
"Mod Loader Version"
)
self.mod_loader_version_label = QLabel(self.app.tr_text("Mod Loader Version"))
self.mod_loader_version_list = QListWidget()
self.mod_loader_version_list.setObjectName(
@@ -60,23 +59,27 @@ class CreateProfilePage(QWidget):
)
# Version list
self.version_list_type_label = QLabel("Version: Vanilla")
self.version_list_label = QLabel("Select a version to continue:")
self.version_list_type_label = QLabel(self.app.tr_text("Version: Vanilla"))
self.version_list_label = QLabel(self.app.tr_text("Select a version to continue:"))
self.version_list = QListWidget()
self.version_list.setObjectName("ListWidget")
# Button
self.create_profile_button = QPushButton("Create Profile")
self.create_profile_button = QPushButton(self.app.tr_text("Create Profile"))
self.create_profile_button.setObjectName("Button")
self.refresh_button = QPushButton("Refresh")
self.refresh_button = QPushButton(self.app.tr_text("Refresh"))
self.refresh_button.setObjectName("Button")
self.install_java_button = QPushButton(self.app.tr_text("Install Java"))
self.install_java_button.setObjectName("Button")
# Bind widget
self.main_layout.addWidget(self.version_list_label)
self.main_layout.addWidget(self.version_list)
self.operate_layout.addWidget(self.version_list_type_label)
self.operate_layout.addStretch()
self.operate_layout.addWidget(self.install_java_button)
self.operate_layout.addWidget(self.refresh_button)
self.operate_layout.addWidget(self.create_profile_button)
@@ -142,6 +145,7 @@ class CreateProfilePage(QWidget):
# Button event
self.create_profile_button.clicked.connect(self.create_profile)
self.install_java_button.clicked.connect(self.create_install_jvm_dialog)
# Update version list while current type is changed
self.version_type_dropdown.currentTextChanged.connect(
@@ -406,7 +410,7 @@ class CreateProfilePage(QWidget):
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected version.",
self.app.tr_text("Timeout while waiting for selected version."),
)
return
@@ -414,7 +418,7 @@ class CreateProfilePage(QWidget):
if not minecraft_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a version before creating profile.",
self.app.tr_text("Selected a version before creating profile."),
)
return
@@ -425,7 +429,7 @@ class CreateProfilePage(QWidget):
if not loader_request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected mod loader.",
self.app.tr_text("Timeout while waiting for selected mod loader."),
)
return
@@ -433,7 +437,7 @@ class CreateProfilePage(QWidget):
if loader_name not in self.loader_list:
self.widget_mgr.signal.show_error_message.emit(
"Loader {} not found.".format(loader_name),
self.app.tr_text("Loader {loader} not found.", loader=loader_name),
)
return
@@ -442,7 +446,10 @@ class CreateProfilePage(QWidget):
if profile_create_handler is None:
self.widget_mgr.signal.show_warning_message.emit(
"Profile creation for loader {} is not implemented.".format(loader_name),
self.app.tr_text(
"Profile creation for loader {loader} is not implemented.",
loader=loader_name,
),
)
return
@@ -453,7 +460,7 @@ class CreateProfilePage(QWidget):
if not loader_version_request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected mod loader version.",
self.app.tr_text("Timeout while waiting for selected mod loader version."),
)
return
@@ -464,8 +471,8 @@ class CreateProfilePage(QWidget):
profile_name, accepted = QInputDialog.getText(
self,
"Create Profile",
"Profile name:",
self.app.tr_text("Create Profile"),
self.app.tr_text("Profile name:"),
text=f"Minecraft {minecraft_version}",
)
@@ -475,7 +482,7 @@ class CreateProfilePage(QWidget):
profile_name = profile_name.strip()
if not profile_name:
self.widget_mgr.signal.show_warning_message.emit(
"Profile name cannot be empty.",
self.app.tr_text("Profile name cannot be empty."),
)
return
@@ -496,7 +503,7 @@ class CreateProfilePage(QWidget):
if not manifest:
self.widget_mgr.signal.show_warning_message.emit(
"Unable to create profile due to manifest fetch failure.",
self.app.tr_text("Unable to create profile due to manifest fetch failure."),
)
return
@@ -508,7 +515,7 @@ class CreateProfilePage(QWidget):
if version_id is None:
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile due to version ID is missing in manifest.",
self.app.tr_text("Unable to create profile due to version ID is missing in manifest."),
)
return
@@ -531,7 +538,7 @@ class CreateProfilePage(QWidget):
return
self.widget_mgr.signal.show_info_message.emit(
f"Profile {profile_id} successfully created.",
self.app.tr_text("Profile {profile_id} successfully created.", profile_id=profile_id),
)
def handle_fabric_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None, switch_to_current=False):
@@ -545,7 +552,7 @@ class CreateProfilePage(QWidget):
"""
if not loader_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a fabric loader version before creating profile.",
self.app.tr_text("Selected a fabric loader version before creating profile."),
)
return
@@ -555,7 +562,7 @@ class CreateProfilePage(QWidget):
)
if not vanilla_manifest:
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile due to vanilla manifest fetch failure.",
self.app.tr_text("Unable to create profile due to vanilla manifest fetch failure."),
)
return
@@ -573,7 +580,7 @@ class CreateProfilePage(QWidget):
if not loader_manifest:
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile due to mod loader manifest fetch failure.",
self.app.tr_text("Unable to create profile due to mod loader manifest fetch failure."),
)
return
@@ -585,7 +592,7 @@ class CreateProfilePage(QWidget):
if loader_info["id"] != version_id:
self.app.logger.error(f"Fabric manifest ID mismatch: expected {version_id}, got {loader_info["id"]}.")
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile. Exising loader version ID mismatch.",
self.app.tr_text("Unable to create profile. Exising loader version ID mismatch."),
)
return
@@ -595,7 +602,7 @@ class CreateProfilePage(QWidget):
f'{loader_info["inheritsFrom"]}, not {minecraft_version}.'
)
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile. Inherits version is mismatched as expected.",
self.app.tr_text("Unable to create profile. Inherits version is mismatched as expected."),
)
return
@@ -620,20 +627,23 @@ class CreateProfilePage(QWidget):
return
self.widget_mgr.signal.show_info_message.emit(
f"Profile {profile_id} with fabric loader successfully created.",
self.app.tr_text(
"Profile {profile_id} with fabric loader successfully created.",
profile_id=profile_id,
),
)
def handle_forge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None):
if not loader_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a forge loader version before creating profile.",
self.app.tr_text("Selected a forge loader version before creating profile."),
)
def handle_neoforge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None):
if not loader_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a neoforge loader version before creating profile.",
self.app.tr_text("Selected a neoforge loader version before creating profile."),
)
#
@@ -666,7 +676,7 @@ class CreateProfilePage(QWidget):
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected type.",
self.app.tr_text("Timeout while waiting for selected type."),
)
return
@@ -674,7 +684,7 @@ class CreateProfilePage(QWidget):
if not selected_type:
self.widget_mgr.signal.show_warning_message.emit(
"No type selected.",
self.app.tr_text("No type selected."),
)
return
@@ -715,14 +725,14 @@ class CreateProfilePage(QWidget):
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected version.",
self.app.tr_text("Timeout while waiting for selected version."),
)
selected_version = request.result()
if not selected_version:
self.widget_mgr.signal.show_warning_message.emit(
"No version selected.",
self.app.tr_text("No version selected."),
)
try:
@@ -731,12 +741,17 @@ class CreateProfilePage(QWidget):
)
except (VersionManifestFetchException, VersionManifestException) as e:
self.widget_mgr.signal.show_error_message.emit(
"Failed to fetch version manifest: {}".format(e.user_message),
self.app.tr_text(
"Failed to fetch version manifest: {error}", error=e.user_message
),
)
return
except Exception as e:
self.widget_mgr.signal.show_error_message.emit(
"An unexpected error occurred while fetching version manifest: {}".format(e),
self.app.tr_text(
"An unexpected error occurred while fetching version manifest: {error}",
error=e,
),
)
return
@@ -768,7 +783,7 @@ class CreateProfilePage(QWidget):
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected loader.",
self.app.tr_text("Timeout while waiting for selected loader."),
)
return
@@ -782,7 +797,10 @@ class CreateProfilePage(QWidget):
if not version_handler:
self.widget_mgr.signal.show_warning_message.emit(
"Selected loader \"{}\" is not implemented".format(loader_name),
self.app.tr_text(
'Selected loader "{loader}" is not implemented',
loader=loader_name,
),
)
return
@@ -791,4 +809,22 @@ class CreateProfilePage(QWidget):
else:
self.app.logger.warning(
"Select mod loader \"loadVersionMethod\" is not callable."
)
)
def create_install_jvm_dialog(self):
manifest = None
selected = self.version_list.currentItem()
if selected:
version_id = (
selected.raw_value
if isinstance(selected, self.DisplayListItem)
else selected.text()
)
try:
resolved = self.manifest_mgr.resolve_manifest(version_id)
manifest = resolved.manifest if resolved else None
except Exception as exc:
self.app.logger.warning(f"Unable to resolve Java requirement for {version_id}: {exc}")
self.dialog = InstallJavaDialog(self.app, manifest=manifest, parent=self)
self.dialog.setModal(False)
self.dialog.show()
+150
View File
@@ -0,0 +1,150 @@
import threading
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QLabel,
QListWidget,
QPushButton,
QVBoxLayout,
)
from manager.game import InstallableJavaRuntime, JavaManager, JavaRuntime
class InstallJavaDialog(QDialog):
"""Install and register launcher-managed Mojang Java runtimes."""
class WorkerSignals(QObject):
components_loaded = Signal(object, object)
installed = Signal(object, object)
runtime_installed = Signal(object)
def __init__(self, app, manifest: dict | None = None, parent=None):
super().__init__(parent)
self.app = app
self.java_manager: JavaManager = app.game_manager.java
self.manifest = manifest or {}
self.worker_signals = self.WorkerSignals(self)
self.worker_signals.components_loaded.connect(self._components_loaded)
self.worker_signals.installed.connect(self._installed)
self.setWindowTitle(self.app.tr_text("Install Java"))
self.setMinimumWidth(480)
layout = QVBoxLayout(self)
self.requirement_label = QLabel(self._requirement_text())
self.runtime_list = QListWidget()
self.component_dropdown = QComboBox()
self.component_dropdown.setEditable(False)
self.status_label = QLabel(self.app.tr_text("Loading available Mojang runtimes..."))
self.install_button = QPushButton(self.app.tr_text("Install"))
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
layout.addWidget(self.requirement_label)
layout.addWidget(QLabel(self.app.tr_text("Detected Java runtimes")))
layout.addWidget(self.runtime_list)
layout.addWidget(QLabel(self.app.tr_text("Mojang runtime component")))
layout.addWidget(self.component_dropdown)
layout.addWidget(self.status_label)
layout.addWidget(self.install_button)
layout.addWidget(buttons)
buttons.rejected.connect(self.reject)
self.install_button.clicked.connect(self.install_selected)
self._refresh_runtime_list()
threading.Thread(target=self._load_components, daemon=True).start()
def _requirement_text(self) -> str:
requirement = self.manifest.get("javaVersion") or {}
major = requirement.get("majorVersion")
component = requirement.get("component")
if major:
return self.app.tr_text(
"This version requires Java {version}.", version=major
) + f" ({component or 'component unknown'})"
return self.app.tr_text("Choose a Mojang Java runtime component to install.")
def _refresh_runtime_list(self) -> None:
self.runtime_list.clear()
for runtime in self.java_manager.list_runtimes():
state = "available" if runtime.valid else "missing"
self.runtime_list.addItem(
f"Java {runtime.major_version} | {runtime.source} | {state}\n{runtime.executable}"
)
def _load_components(self) -> None:
try:
runtimes = self.java_manager.list_installable_runtimes()
self.worker_signals.components_loaded.emit(runtimes, None)
except Exception as exc:
self.worker_signals.components_loaded.emit([], exc)
def _components_loaded(self, runtimes: list[InstallableJavaRuntime], error) -> None:
requirement = self.manifest.get("javaVersion") or {}
required = requirement.get("component")
required_major = requirement.get("majorVersion")
self.component_dropdown.clear()
for runtime in runtimes:
major = runtime.major_version
if runtime.component == required and required_major is not None:
# The Minecraft version manifest is authoritative for its own
# runtime requirement if Mojang's version name is unusual.
major = int(required_major)
label = f"Java {major} | {runtime.component}" if major is not None else runtime.component
if runtime.raw_version:
label += f" ({runtime.raw_version})"
self.component_dropdown.addItem(label, runtime.component)
if required:
index = self.component_dropdown.findData(required)
if index < 0:
label = f"Java {required_major} | {required}" if required_major else required
self.component_dropdown.addItem(label, required)
index = self.component_dropdown.count() - 1
self.component_dropdown.setCurrentIndex(index)
self.install_button.setEnabled(self.component_dropdown.count() > 0)
self.status_label.setText(
self.app.tr_text("Unable to load component list: {error}", error=error)
if error else self.app.tr_text("Ready to install.")
)
def install_selected(self) -> None:
component = self.component_dropdown.currentData()
if not component:
self.status_label.setText(self.app.tr_text("Select a runtime component."))
return
self.install_button.setEnabled(False)
self.component_dropdown.setEnabled(False)
self.status_label.setText(self.app.tr_text("Installing {component}...", component=component))
callback, task_id = self.app.game_manager.manifest.create_download_progress_callback(
self.app.tr_text("Installing Java ({component})", component=component)
)
def worker():
try:
runtime = self.java_manager.install_runtime(
component,
progress_callback=callback,
redownload_callback=self.app.game_manager.game_file.redownload_files,
)
self.app.widget_manager.download_signal.finished.emit(task_id)
self.worker_signals.installed.emit(runtime, None)
except Exception as exc:
self.app.widget_manager.download_signal.failed.emit(task_id, str(exc))
self.worker_signals.installed.emit(None, exc)
threading.Thread(target=worker, daemon=True).start()
def _installed(self, runtime: JavaRuntime | None, error) -> None:
self.install_button.setEnabled(True)
self.component_dropdown.setEnabled(True)
if error:
self.status_label.setText(self.app.tr_text("Installation failed: {error}", error=error))
return
self._refresh_runtime_list()
self.status_label.setText(self.app.tr_text(
"Java {version} installed successfully.", version=runtime.major_version
))
self.runtime_installed.emit(runtime)
+27 -18
View File
@@ -208,7 +208,7 @@ class LaunchProfilePage(QWidget):
self.bottom_layout = QHBoxLayout()
# Widgets
self.status_label = QLabel("Select a profile to launch")
self.status_label = QLabel(self.app.tr_text("Select a profile to launch"))
self.status_label.setStyleSheet("font-size: 12px; font-weight: 600;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -221,7 +221,7 @@ class LaunchProfilePage(QWidget):
self.profile_popup.profile_selected.connect(self.select_profile)
self.profile_popup.closed.connect(self.refresh_profile_button)
launch_button = QPushButton("Launch Game")
launch_button = QPushButton(self.app.tr_text("Launch Game"))
launch_button.setObjectName("launchButton")
launch_button.setMinimumWidth(120)
launch_button.setMinimumHeight(42)
@@ -258,7 +258,7 @@ class LaunchProfilePage(QWidget):
# self.profile_frame.setGraphicsEffect(shadow)
# Header
self.header_label = QLabel("Launch Profile")
self.header_label = QLabel(self.app.tr_text("Launch Profile"))
self.header_label.setObjectName("headerLabel")
self.header_layout = QHBoxLayout(self.profile_overlay)
@@ -322,16 +322,20 @@ class LaunchProfilePage(QWidget):
:return:
"""
if not result.success:
self.status_label.setText("Launch Failed")
self.status_label.setText(self.app.tr_text("Launch Failed"))
self.widget_mgr.signal.show_error_message.emit(
f"Unable to launch profile "
f"{launch_profile.display_name}: "
f"{result.error_message}"
self.app.tr_text(
"Unable to launch profile {profile}: {error}",
profile=launch_profile.display_name,
error=result.error_message,
)
)
self.status_label_cleanup()
return
# Create log view
log_window = ProcessLogWindow(
self.app,
result.process,
launch_profile.profile_id,
title=(
@@ -350,12 +354,17 @@ class LaunchProfilePage(QWidget):
self.process_log_windows.append(log_window)
log_window.show()
self.status_label.setText("Launched!")
self.status_label.setText(self.app.tr_text("Launched!"))
# Cleanup for status label
self.status_label_cleanup()
def status_label_cleanup(self):
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(lambda: self.status_label.setText("Select a profile to launch"))
timer.timeout.connect(lambda: self.status_label.setText(
self.app.tr_text("Select a profile to launch")
))
timer.start(3000)
def load_profiles(self):
@@ -364,7 +373,7 @@ class LaunchProfilePage(QWidget):
if not profiles:
self.profile_button.setProperty("profile_id", None)
self.profile_button.name_label.setText("No profiles available")
self.profile_button.name_label.setText(self.app.tr_text("No profiles available"))
self.profile_button.version_label.clear()
return
@@ -441,30 +450,30 @@ class LaunchProfilePage(QWidget):
if profile_id is None:
self.widget_mgr.signal.show_warning_message.emit(
"There is no profile is selected."
" Please create one first."
self.app.tr_text("There is no profile is selected. Please create one first.")
)
return
self.status_label.setText("Preparing to launch...")
self.status_label.setText(self.app.tr_text("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.")
self.status_label.setText(self.app.tr_text("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.app.tr_text("Launch Request"),
self.app.tr_text(
"Target profile {profile} is running. Are you sure you want to launch it again?",
profile=launch_profile.display_name,
),
)
self.widget_mgr.signal.askyesno.emit(request)
if not request.wait(60):
self.widget_mgr.signal.show_error_message.emit(
"Time out waiting for request for launch."
self.app.tr_text("Time out waiting for request for launch.")
)
return
+229 -42
View File
@@ -6,23 +6,33 @@ from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtWidgets import (
QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox,
QVBoxLayout, QWidget,
QVBoxLayout, QWidget, QMenu, QComboBox,
)
from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest
from manager.game import AvailableVersion
from pages.add_modded_version import AddModdedVersionDialog
class ManageProfileItem(QPushButton):
selected = Signal(object)
def __init__(self, app, profile: ProfileSummary, parent=None):
def __init__(self, app, profile: ProfileSummary,
parent=None,
delete_callback=None,
duplicate_callback=None):
super().__init__(parent)
self.app = app
self.profile = profile
self.setObjectName("manageProfileItem")
self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMinimumHeight(64)
# Callback
self.delete_callback = delete_callback
self.duplicate_callback = duplicate_callback
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(12)
@@ -48,19 +58,57 @@ class ManageProfileItem(QPushButton):
layout.addLayout(texts, 1)
self.clicked.connect(lambda: self.selected.emit(self.profile))
# Enable right click menu
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_right_click_menu)
def show_right_click_menu(self, position):
menu = QMenu(self)
duplicate_action = menu.addAction(self.app.tr_text("Duplicate"))
remove_action = menu.addAction(self.app.tr_text("Delete"))
menu.addAction(duplicate_action)
menu.addAction(remove_action)
def duplicate_callback():
self.duplicate_callback(self.profile)
def delete_callback():
self.delete_callback(self.profile)
if callable(self.duplicate_callback):
duplicate_action.triggered.connect(duplicate_callback)
if callable(self.delete_callback):
remove_action.triggered.connect(delete_callback)
global_position = self.mapToGlobal(position)
menu.exec(global_position)
class ManageProfilePage(QWidget):
"""Select a profile on the left and edit its launcher settings on the right."""
"""
Profile manage page
"""
VERSION_CATEGORIES = (
("Vanilla", "vanilla"),
("Modded", "modded"),
("Custom", "custom"),
)
def __init__(self, app, parent=None):
super().__init__(parent)
self.app = app
self.profile_manager: ProfileManager = app.profile_manager
self.manifest_manager = app.game_manager.manifest
self.selected_profile_id: str | None = None
self.profile_items: dict[str, ManageProfileItem] = {}
self.version_options: list[AvailableVersion] = []
root = QVBoxLayout(self)
title = QLabel("Manage Profiles (Experimental)")
title = QLabel(self.app.tr_text("Manage Profiles"))
title.setObjectName("manageTitle")
root.addWidget(title)
@@ -68,6 +116,7 @@ class ManageProfilePage(QWidget):
columns.setSpacing(12)
root.addLayout(columns, 1)
# Widgets
self.list_scroll = self._scroll_area("manageProfileListScroll")
self.list_content = QWidget()
self.list_layout = QVBoxLayout(self.list_content)
@@ -87,7 +136,7 @@ class ManageProfilePage(QWidget):
self.options_scroll.setWidget(self.options_content)
columns.addWidget(self.options_scroll, 3)
self.empty_label = QLabel("Select a profile from the list to edit its settings.")
self.empty_label = QLabel(self.app.tr_text("Select a profile from the list to edit its settings."))
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setWordWrap(True)
self.options_layout.addWidget(self.empty_label, 1)
@@ -101,45 +150,58 @@ class ManageProfilePage(QWidget):
form.setVerticalSpacing(12)
self.name_edit = QLineEdit()
self.version_edit = QLineEdit()
self.type_edit = QLineEdit()
self.version_dropdown = QComboBox()
self.version_dropdown.setObjectName("Dropdown")
self.version_dropdown.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
self.version_dropdown.setSizeAdjustPolicy(
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon
)
self.version_dropdown.setMinimumContentsLength(24)
self.type_dropdown = QComboBox()
self.type_dropdown.setObjectName("Dropdown")
self.game_dir_edit = QLineEdit()
self.java_args_edit = QLineEdit()
self.icon_edit = QLineEdit()
self.width_spin = self._dimension_spin()
self.height_spin = self._dimension_spin()
form.addRow("Profile name", self.name_edit)
form.addRow("Version ID", self.version_edit)
form.addRow("Profile type", self.type_edit)
form.addRow("Game directory", self.game_dir_edit)
form.addRow("Java arguments", self.java_args_edit)
form.addRow("Icon", self.icon_edit)
form.addRow(self.app.tr_text("Profile name"), self.name_edit)
form.addRow(self.app.tr_text("Type"), self.type_dropdown)
form.addRow(self.app.tr_text("Version"), self.version_dropdown)
form.addRow(self.app.tr_text("Game directory"), self.game_dir_edit)
form.addRow(self.app.tr_text("Java arguments"), self.java_args_edit)
form.addRow(self.app.tr_text("Icon"), self.icon_edit)
resolution = QHBoxLayout()
resolution.addWidget(self.width_spin)
resolution.addWidget(QLabel("×"))
resolution.addWidget(self.height_spin)
form.addRow("Resolution", resolution)
form.addRow(self.app.tr_text("Resolution"), resolution)
editor_layout.addLayout(form)
file_actions = QHBoxLayout()
open_button = QPushButton("Open Profile Folder")
add_version_button = QPushButton(self.app.tr_text("Add Modded Version"))
add_version_button.clicked.connect(self.open_add_modded_version_dialog)
open_button = QPushButton(self.app.tr_text("Open Profile Folder"))
open_button.clicked.connect(self.open_profile_folder)
import_button = QPushButton("Import Mods…")
import_button = QPushButton(self.app.tr_text("Import Mods…"))
import_button.clicked.connect(self.import_mods)
file_actions.addWidget(add_version_button)
file_actions.addWidget(open_button)
file_actions.addWidget(import_button)
file_actions.addStretch()
editor_layout.addLayout(file_actions)
actions = QHBoxLayout()
duplicate_button = QPushButton("Duplicate")
duplicate_button = QPushButton(self.app.tr_text("Duplicate"))
duplicate_button.clicked.connect(self.duplicate_profile)
delete_button = QPushButton("Delete")
delete_button = QPushButton(self.app.tr_text("Delete"))
delete_button.setObjectName("dangerButton")
delete_button.clicked.connect(self.delete_profile)
save_button = QPushButton("Save Changes")
save_button = QPushButton(self.app.tr_text("Save Changes"))
save_button.setObjectName("saveProfileButton")
save_button.clicked.connect(self.save_profile)
actions.addWidget(duplicate_button)
@@ -152,9 +214,11 @@ class ManageProfilePage(QWidget):
self.options_layout.addStretch()
self.editor.hide()
self.profile_manager.profiles_changed.connect(self.load_profiles)
self.type_dropdown.currentIndexChanged.connect(self._filter_versions)
self.profile_manager.profiles_changed.connect(self.refresh_profiles)
self.app.apply_qss(Path("manage_profile.qss"), self.setStyleSheet)
self.load_profiles()
self._load_type_dropdown()
self.refresh_profiles()
@staticmethod
def _scroll_area(name: str) -> QScrollArea:
@@ -165,31 +229,68 @@ class ManageProfilePage(QWidget):
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return area
@staticmethod
def _dimension_spin() -> QSpinBox:
def _dimension_spin(self) -> QSpinBox:
spin = QSpinBox()
spin.setRange(0, 16384)
spin.setSpecialValueText("Default")
spin.setSpecialValueText(self.app.tr_text("Default"))
spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return spin
def _load_type_dropdown(self) -> None:
self.type_dropdown.blockSignals(True)
self.type_dropdown.clear()
for display_name, category in self.VERSION_CATEGORIES:
self.type_dropdown.addItem(self.app.tr_text(display_name), category)
self.type_dropdown.blockSignals(False)
def refresh_profiles(self) -> None:
self.version_options = self.manifest_manager.list_available_profile_versions()
self.load_profiles()
def _filter_versions(self, _index: int = -1) -> None:
selected_category = self.type_dropdown.currentData()
selected_option = self.version_dropdown.currentData()
selected_id = (
selected_option.id
if isinstance(selected_option, AvailableVersion)
else None
)
self.version_dropdown.blockSignals(True)
self.version_dropdown.clear()
for option in self.version_options:
if option.source == selected_category:
self.version_dropdown.addItem(option.display_name, option)
if selected_id is not None:
self._select_version(selected_id)
self.version_dropdown.blockSignals(False)
def load_profiles(self) -> None:
while self.list_layout.count():
item = self.list_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.profile_items.clear()
profiles = self.profile_manager.list_profiles()
for profile in profiles:
item = ManageProfileItem(self.app, profile, self.list_content)
item = ManageProfileItem(self.app, profile,
self.list_content,
duplicate_callback=self.duplicate_profile,
delete_callback=self.delete_profile)
item.selected.connect(self.select_profile)
self.profile_items[profile.id] = item
self.list_layout.addWidget(item)
available_ids = {profile.id for profile in profiles}
if self.selected_profile_id not in available_ids:
self.selected_profile_id = None
if self.selected_profile_id is None and profiles:
current = self.profile_manager.get_current_profile_id()
selected = next((p for p in profiles if p.id == current), profiles[0])
@@ -203,19 +304,53 @@ class ManageProfilePage(QWidget):
def select_profile(self, profile: ProfileSummary) -> None:
detail = self.profile_manager.get_profile(profile.id)
if detail is None:
return
self.selected_profile_id = profile.id
for profile_id, item in self.profile_items.items():
item.setChecked(profile_id == profile.id)
self._set_detail(detail)
self.empty_label.hide()
self.editor.show()
def _select_version(self, version_id: str) -> None:
for index in range(self.version_dropdown.count()):
data = self.version_dropdown.itemData(index)
if isinstance(data, AvailableVersion) and data.id == version_id:
self.version_dropdown.setCurrentIndex(index)
return
def _select_type(self, category: str) -> None:
index = self.type_dropdown.findData(category)
if index >= 0:
self.type_dropdown.setCurrentIndex(index)
def _set_detail(self, profile: ProfileDetail) -> None:
self.name_edit.setText(profile.name)
self.version_edit.setText(profile.version_id)
self.type_edit.setText(profile.version_type)
option = next(
(item for item in self.version_options if item.id == profile.version_id),
None,
)
# Preserve profiles imported from another launcher, even when their
# version manifest is not available locally anymore.
if option is None:
option = AvailableVersion(
id=profile.version_id,
display_name=f"{profile.version_id} (Unavailable)",
version_type=profile.version_type or "custom",
source="custom",
)
self.version_options.append(option)
self._select_type(option.source)
self._filter_versions()
self._select_version(profile.version_id)
self.game_dir_edit.setText(str(profile.game_dir or ""))
self.java_args_edit.setText(profile.java_args)
self.icon_edit.setText(profile.icon)
@@ -226,87 +361,139 @@ class ManageProfilePage(QWidget):
def _profile_dir(self) -> Path | None:
if self.selected_profile_id is None:
return None
detail = self.profile_manager.get_profile(self.selected_profile_id)
if detail is None:
return None
return detail.game_dir or Path(self.app.default_game_dir)
def save_profile(self) -> None:
if self.selected_profile_id is None:
return
width, height = self.width_spin.value(), self.height_spin.value()
if (width == 0) != (height == 0):
self.app.widget_manager.signal.show_warning_message.emit(
"Set both resolution values, or set both to Default."
self.app.tr_text("Set both resolution values, or set both to Default.")
)
return
game_dir_text = self.game_dir_edit.text().strip()
version = self.version_dropdown.currentData()
if not isinstance(version, AvailableVersion):
self.app.widget_manager.signal.show_warning_message.emit(
self.app.tr_text("Select a version.")
)
return
request = UpdateProfileRequest(
name=self.name_edit.text().strip(),
version_id=self.version_edit.text().strip(),
version_type=self.type_edit.text().strip(),
version_id=version.id,
version_type=version.version_type,
game_dir=Path(game_dir_text) if game_dir_text else None,
java_args=self.java_args_edit.text(),
icon=self.icon_edit.text().strip(),
resolution=(width, height) if width and height else None,
)
if self.profile_manager.validate_update(request).is_valid and self.profile_manager.update_profile(
self.selected_profile_id, request
):
self.profile_manager.save()
self.app.widget_manager.signal.show_info_message.emit("Profile settings saved.")
self.app.widget_manager.signal.show_info_message.emit(
self.app.tr_text("Profile settings saved.")
)
else:
errors = self.profile_manager.validate_update(request).errors
if errors:
self.app.widget_manager.signal.show_warning_message.emit("\n".join(errors.values()))
def open_add_modded_version_dialog(self) -> None:
dialog = AddModdedVersionDialog(self.app, self)
dialog.version_created.connect(self._modded_version_created)
dialog.exec()
def _modded_version_created(self, version_id: str) -> None:
self.version_options = self.manifest_manager.list_available_profile_versions()
# self._select_type("modded")
# self._filter_versions()
# self._select_version(version_id)
self.app.widget_manager.signal.show_info_message.emit(
self.app.tr_text("Modded version {version_id} added.", version_id=version_id)
)
def open_profile_folder(self) -> None:
profile_dir = self._profile_dir()
if profile_dir is None:
return
profile_dir.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(profile_dir.resolve())))
def import_mods(self) -> None:
profile_dir = self._profile_dir()
if profile_dir is None:
return
files, _ = QFileDialog.getOpenFileNames(
self, "Import Mods", "", "Minecraft mods (*.jar *.zip);;All files (*)"
self, self.app.tr_text("Import Mods"), "", "Minecraft mods (*.jar *.zip);;All files (*)"
)
if not files:
return
mods_dir = profile_dir / "mods"
mods_dir.mkdir(parents=True, exist_ok=True)
try:
for source in files:
shutil.copy2(source, mods_dir / Path(source).name)
except OSError as error:
self.app.widget_manager.signal.show_error_message.emit(f"Unable to import mod: {error}")
self.app.widget_manager.signal.show_error_message.emit(
self.app.tr_text("Unable to import mod: {error}", error=error)
)
return
self.app.widget_manager.signal.show_info_message.emit(
f"Imported {len(files)} mod(s) into {mods_dir}."
self.app.tr_text(
"Imported {count} mod(s) into {directory}.",
count=len(files), directory=mods_dir,
)
)
def duplicate_profile(self) -> None:
if self.selected_profile_id is None:
def duplicate_profile(self, profile: ProfileSummary | None=None) -> None:
if self.selected_profile_id is None and profile is None:
return
new_id = self.profile_manager.duplicate_profile(self.selected_profile_id)
profile_id = self.selected_profile_id
new_id = self.profile_manager.duplicate_profile(profile_id)
if new_id:
self.selected_profile_id = new_id
self.load_profiles()
self.profile_manager.save()
def delete_profile(self) -> None:
if self.selected_profile_id is None:
def delete_profile(self, profile: ProfileSummary | None=None) -> None:
if self.selected_profile_id is None and profile is None:
return
profile_id = self.selected_profile_id
answer = QMessageBox.question(
self, "Delete Profile", "Delete this profile? Game files will not be removed.",
self,
self.app.tr_text("Delete Profile"),
self.app.tr_text("Delete this profile? Game files will not be removed."),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
profile_id = self.selected_profile_id
self.selected_profile_id = None
if self.profile_manager.remove_profile(profile_id):
self.selected_profile_id = None
self.profile_manager.save()
+14 -9
View File
@@ -31,24 +31,25 @@ class ProcessLogWindow(QMainWindow):
A Simple client log view
"""
def __init__(self, process: subprocess.Popen, profile_id: str, title: str = "Game Log",
def __init__(self, app, process: subprocess.Popen, profile_id: str, title: str = "Game Log",
parent: QWidget | None = None, finished_callback: Callable[[str, int], None]=None) -> None:
super().__init__(parent)
self.app = app
self.process = process
self.profile_id = profile_id
self._signals = _ProcessSignals(self)
self._last_error_message: str | None = None
self._stderr_lines: deque[str] = deque(maxlen=500)
self.setWindowTitle(title)
self.setWindowTitle(self.app.tr_text(title))
self.resize(700, 720)
container = QWidget(self)
layout = QVBoxLayout(container)
controls = QHBoxLayout()
self.status_label = QLabel(f"Running (PID {process.pid})")
self.kill_button = QPushButton("Kill Process")
self.status_label = QLabel(self.app.tr_text("Running (PID {pid})", pid=process.pid))
self.kill_button = QPushButton(self.app.tr_text("Kill Process"))
self.kill_button.clicked.connect(self.kill_process)
self.log_output = QPlainTextEdit()
@@ -135,18 +136,20 @@ class ProcessLogWindow(QMainWindow):
return
self.kill_button.setEnabled(False)
self.status_label.setText("Killing process...")
self.status_label.setText(self.app.tr_text("Killing process..."))
try:
self.process.kill()
except Exception as exc:
self.kill_button.setEnabled(True)
self.status_label.setText("Unable to kill process")
self.status_label.setText(self.app.tr_text("Unable to kill process"))
self.append_message(f"[launcher] Unable to kill process: {exc}")
def _process_finished(self, exit_code: int) -> None:
self.kill_button.setEnabled(False)
self.status_label.setText(f"Process exited with code {exit_code}")
self.status_label.setText(self.app.tr_text(
"Process exited with code {exit_code}", exit_code=exit_code
))
self.append_message(f"[launcher] Process exited with code {exit_code}.")
if exit_code != 0 and self._last_error_message:
@@ -164,8 +167,10 @@ class ProcessLogWindow(QMainWindow):
messagebox.error_with_plain_text(
self,
title="Minecraft Client Error",
message=f"Minecraft exited with code {exit_code}.",
title=self.app.tr_text("Minecraft Client Error"),
message=self.app.tr_text(
"Minecraft exited with code {exit_code}.", exit_code=exit_code
),
content=error_content,
)
+65 -20
View File
@@ -111,9 +111,44 @@ class SettingsPage(QWidget):
root_layout.addWidget(self.stack)
self.app.apply_qss(Path("settings.qss"), self.setStyleSheet)
self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty)
self.theme_combo.currentIndexChanged.connect(self._mark_settings_dirty)
self.client_id_edit.textEdited.connect(self._mark_settings_dirty)
self.theme_combo.currentIndexChanged.connect(
self._apply_selected_theme
)
self.app.i18n.language_changed.connect(self.retranslate_ui)
def _apply_selected_theme(self) -> None:
theme = self.theme_combo.currentData()
self.app.apply_theme(theme)
self._mark_settings_dirty()
def retranslate_ui(self, *_args) -> None:
"""Rebuild this page so every label uses the active catalog."""
current_page = self.stack.currentIndex()
while self.stack.count():
old_page = self.stack.widget(0)
self.stack.removeWidget(old_page)
old_page.deleteLater()
self.main_page = self._create_main_page()
self.general_page = self._create_general_page()
self.appearance_page = self._create_appearance_page()
self.account_page = self._create_account_page()
self.about_page = self._create_about_page()
for page in (
self.main_page,
self.general_page,
self.appearance_page,
self.account_page,
self.about_page,
):
self.stack.addWidget(page)
self.stack.setCurrentIndex(min(current_page, self.stack.count() - 1))
self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty)
self.client_id_edit.textEdited.connect(self._mark_settings_dirty)
self.theme_combo.currentIndexChanged.connect(self._apply_selected_theme)
def _create_main_page(self) -> QWidget:
page = QWidget()
page.setObjectName("settingsMainPage")
@@ -132,16 +167,17 @@ class SettingsPage(QWidget):
scroll_layout.setContentsMargins(24, 20, 24, 24)
scroll_layout.setSpacing(12)
title = QLabel("Settings")
tr = self.app.tr_text
title = QLabel(tr("Settings"))
title.setObjectName("settingsPageTitle")
scroll_layout.addWidget(title)
scroll_layout.addSpacing(8)
pages = (
("General", "Language and launcher directories", "general_page"),
("Appearance", "Launcher theme", "appearance_page"),
("Account", "Microsoft authentication settings", "account_page"),
("About", "Version and project information", "about_page"),
(tr("General"), tr("Language and launcher directories"), "general_page"),
(tr("Appearance"), tr("Launcher theme"), "appearance_page"),
(tr("Account"), tr("Microsoft authentication settings"), "account_page"),
(tr("About"), tr("Version and project information"), "about_page"),
)
for title_text, description, page_name in pages:
button = SettingsItem(title_text, description)
@@ -157,21 +193,22 @@ class SettingsPage(QWidget):
return page
def _create_general_page(self) -> QWidget:
page = SettingsSubPage("General", self._go_back)
tr = self.app.tr_text
page = SettingsSubPage(tr("General"), self._go_back)
current = self.settings.get("general", GeneralSettings)
page.content_layout.addWidget(QLabel("Current launcher working directory"))
page.content_layout.addWidget(QLabel(tr("Current launcher working directory")))
launcher_dir = QLineEdit(self.app.work_dir.as_posix())
launcher_dir.setReadOnly(True)
page.content_layout.addWidget(launcher_dir)
page.content_layout.addWidget(QLabel("Current program directory"))
page.content_layout.addWidget(QLabel(tr("Current program directory")))
program_dir = QLineEdit(self.app.program_dir.as_posix())
program_dir.setReadOnly(True)
page.content_layout.addWidget(program_dir)
page.content_layout.addSpacing(8)
page.content_layout.addWidget(QLabel("Language"))
page.content_layout.addWidget(QLabel(tr("Language")))
self.language_combo = self._choice_combo(
"general", "language", LANGUAGE_LABELS, current.language
)
@@ -179,10 +216,11 @@ class SettingsPage(QWidget):
return page
def _create_appearance_page(self) -> QWidget:
page = SettingsSubPage("Appearance", self._go_back)
tr = self.app.tr_text
page = SettingsSubPage(tr("Appearance"), self._go_back)
current = self.settings.get("appearance", AppearanceSettings)
page.content_layout.addWidget(QLabel("Theme"))
page.content_layout.addWidget(QLabel(tr("Theme")))
self.theme_combo = self._choice_combo(
"appearance", "theme", THEME_LABELS, current.theme
)
@@ -190,30 +228,32 @@ class SettingsPage(QWidget):
return page
def _create_account_page(self) -> QWidget:
page = SettingsSubPage("Account", self._go_back)
tr = self.app.tr_text
page = SettingsSubPage(tr("Account"), self._go_back)
current = self.settings.get("account", AccountSettings)
page.content_layout.addWidget(QLabel("Microsoft OAuth Client ID"))
page.content_layout.addWidget(QLabel(tr("Microsoft OAuth Client ID")))
self.client_id_edit = QLineEdit(str(current.client_id))
self.client_id_edit.setPlaceholderText(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)
page.content_layout.addWidget(self.client_id_edit)
help_label = QLabel(
help_label = QLabel(tr(
"The Client ID must belong to an Azure application configured "
"for Microsoft device-code authentication."
)
))
help_label.setObjectName("settingsHelpText")
help_label.setWordWrap(True)
page.content_layout.addWidget(help_label)
return page
def _create_about_page(self) -> QWidget:
page = SettingsSubPage("About", self._go_back)
tr = self.app.tr_text
page = SettingsSubPage(tr("About"), self._go_back)
brand_label = QLabel(LAUNCHER_NAME)
brand_label.setObjectName("settingsBrand")
version_label = QLabel(f"Version: {LAUNCHER_VERSION}")
version_label = QLabel(tr("Version: {version}", version=LAUNCHER_VERSION))
version_label.setObjectName("settingsVersion")
description = QPlainTextEdit(LAUNCHER_DESCRIPTION)
description.setObjectName("settingsDescription")
@@ -233,7 +273,10 @@ class SettingsPage(QWidget):
) -> QComboBox:
combo = QComboBox()
for value in self.settings.choices(section, field):
combo.addItem(labels.get(value, str(value)), value)
combo.addItem(
self.app.tr_text(labels.get(value, str(value))),
value,
)
index = combo.findData(current)
combo.setCurrentIndex(max(index, 0))
return combo
@@ -257,10 +300,12 @@ class SettingsPage(QWidget):
self.settings.save()
except Exception as error:
if show_error:
QMessageBox.critical(self, "Settings error", str(error))
QMessageBox.critical(self, self.app.tr_text("Settings error"), str(error))
return False
self._settings_dirty = False
selected_language = data["general"]["language"]
self.app.apply_language(selected_language)
return True
def _open_page(self, page: QWidget) -> None:
+7 -7
View File
@@ -43,31 +43,31 @@ class TestPage(QWidget):
self.resize(800, 600)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint)
self.button = QPushButton("Fetch Version Data")
self.button = QPushButton(self.app.tr_text("Fetch Version Data"))
self.button.setObjectName("ActionButton")
self.button.clicked.connect(self.run_test)
self.button_2 = QPushButton("Download client")
self.button_2 = QPushButton(self.app.tr_text("Download client"))
self.button_2.setObjectName("ActionButton")
self.button_2.clicked.connect(self.run_download_client)
self.button_3 = QPushButton("Download libraries")
self.button_3 = QPushButton(self.app.tr_text("Download libraries"))
self.button_3.setObjectName("ActionButton")
self.button_3.clicked.connect(self.run_download_libraries)
self.button_4 = QPushButton("Refresh versions")
self.button_4 = QPushButton(self.app.tr_text("Refresh versions"))
self.button_4.setObjectName("ActionButton")
self.button_4.clicked.connect(self.load_all_version)
self.button_5 = QPushButton("Download assets")
self.button_5 = QPushButton(self.app.tr_text("Download assets"))
self.button_5.setObjectName("ActionButton")
self.button_5.clicked.connect(self.download_assets)
self.button_6 = QPushButton("Parse Arguments")
self.button_6 = QPushButton(self.app.tr_text("Parse Arguments"))
self.button_6.setObjectName("ActionButton")
self.button_6.clicked.connect(self.parse_argument)
self.button_7 = QPushButton("Launch Game! (Offline)")
self.button_7 = QPushButton(self.app.tr_text("Launch Game! (Offline)"))
self.button_7.setObjectName("ActionButton")
self.button_7.clicked.connect(self.launch_offline_game)