Now vanilla (or with Fabric loader) profile creation is implemented.

This commit is contained in:
wei
2026-07-31 22:14:34 +08:00
parent 8d865c92a9
commit b5df1ec1ca
7 changed files with 668 additions and 127 deletions
+528 -78
View File
@@ -4,23 +4,16 @@ from pathlib import Path
from PySide6.QtCore import QSize, Qt, QObject, Signal, Slot
from PySide6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QListWidget, QPushButton, QLabel, QHBoxLayout, \
QSizePolicy
QSizePolicy, QListWidgetItem, QInputDialog
from core_lib.common.exception import VersionManifestFetchException, VersionManifestException
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_all_versions, get_available_version_types
from manager.game import ManifestManager, GameManager
from manager.profile import CreateProfileRequest
from manager.widgets import SignalTransferItem, WidgetManager
LOADER_LIST = {
"fabric": {
"type": "modded",
"loadVersionMethod": None,
},
"Don't Install": {
"type": "vanilla",
"loadVersionMethod": "official",
}
}
class CreateProfilePage(QWidget):
def __init__(self, app, parent=None):
super(CreateProfilePage, self).__init__(parent)
@@ -73,8 +66,8 @@ class CreateProfilePage(QWidget):
self.version_list.setObjectName("ListWidget")
# Button
self.create_profile = QPushButton("Create Profile")
self.create_profile.setObjectName("Button")
self.create_profile_button = QPushButton("Create Profile")
self.create_profile_button.setObjectName("Button")
self.refresh_button = QPushButton("Refresh")
self.refresh_button.setObjectName("Button")
@@ -85,7 +78,7 @@ class CreateProfilePage(QWidget):
self.operate_layout.addWidget(self.version_list_type_label)
self.operate_layout.addStretch()
self.operate_layout.addWidget(self.refresh_button)
self.operate_layout.addWidget(self.create_profile)
self.operate_layout.addWidget(self.create_profile_button)
self.mod_config_fields_layout.addWidget(
self.mod_loader_label
@@ -143,15 +136,38 @@ class CreateProfilePage(QWidget):
# Signals
self.signals = self.PageSignal()
self.signals.get_selected_type.connect(self.get_selected_type)
self.signals.get_selected_version.connect(self.get_selected_version)
self.signals.update_type_dropdown.connect(self.update_type_dropdown)
self.signals.update_version_dropdown.connect(self.update_version_dropdown)
self.signals.get_selected_item.connect(self.get_selected_item)
self.signals.update_list.connect(self.update_list)
self.signals.clean_list.connect(self.clean_list)
# events
self.type_list_loading = threading.Event()
self.version_list_loading = threading.Event()
# Button event
self.create_profile_button.clicked.connect(self.create_profile)
# Update version list while current type is changed
self.version_type_dropdown.currentTextChanged.connect(
self.start_load_version_list
)
self.version_list.currentTextChanged.connect(
self.start_load_mod_loader_version
)
self.mod_loader_dropdown.currentTextChanged.connect(
self.start_load_mod_loader_version
)
self.loader_list = {
"Don't Install": {
"type": "vanilla",
"loadVersionMethod": self.load_vanilla_version_list,
"profileCreateHandler": self.handle_vanilla_profile_create,
},
"Fabric": {
"type": "modded",
"loadVersionMethod": getattr(self, "load_fabric_version_list", None),
"profileCreateHandler": self.handle_fabric_profile_create,
}
}
self.init_all()
class PageSignal(QObject):
@@ -159,19 +175,75 @@ class CreateProfilePage(QWidget):
load_version_list = Signal()
# widget
update_list = Signal(object)
clean_list = Signal(object)
update_version_dropdown = Signal(list)
update_type_dropdown = Signal(list)
update_mod_loader_icon_label = Signal(object)
update_mod_loader_dropdown = Signal(list)
update_mod_loader_version_list = Signal(list)
get_selected_version = Signal(object)
get_selected_type = Signal(object)
get_selected_item = Signal(object)
#
# Signal Handler
#
class UpdateListRequest(object):
def __init__(self, target_list: QListWidget | QComboBox, items: list[str], clean: bool=True,
current_index: int | None = None, custom_handler: Callable = None,
block_signals: bool = False, complete_callback: Callable = None):
self.target_list = target_list
self.items: list[str | dict] = items
self.clean = clean
self.current_index = current_index
self.custom_handler = custom_handler
self.block_signals = block_signals
self.complete_callback = complete_callback
@Slot(object)
def update_list(self, request: UpdateListRequest):
target_list = request.target_list
items = request.items
if not isinstance(target_list, QListWidget | QComboBox):
raise TypeError(
"Target list must be a QListWidget or QComboBox."
)
if request.block_signals:
target_list.blockSignals(True)
# Handle update
try:
if request.clean:
target_list.clear()
if callable(request.custom_handler):
handler = request.custom_handler
handler(request)
else:
target_list.addItems(items)
index = request.current_index
if index is None:
return
# Check and set current index
if 0 <= request.current_index < target_list.count():
if isinstance(target_list, QComboBox):
target_list.setCurrentIndex(index)
elif isinstance(target_list, QListWidget):
target_list.setCurrentRow(index)
else:
self.app.logger.warning(
"Target list index out of range. Current index is {}.".format(
request.current_index
)
)
finally:
if request.block_signals:
target_list.blockSignals(False)
if callable(request.complete_callback):
request.complete_callback()
@staticmethod
def clean_list(target_list: QListWidget | QComboBox):
if not isinstance(target_list, QListWidget | QComboBox):
@@ -179,33 +251,44 @@ class CreateProfilePage(QWidget):
target_list.clear()
@Slot(dict)
def get_selected_type(self, item: SignalTransferItem):
if not isinstance(item, SignalTransferItem):
raise TypeError("Item is not a SignalTransferItem")
class GetSelectedItem(SignalTransferItem):
def __init__(self, target_list: QListWidget | QComboBox):
SignalTransferItem.__init__(self)
self.target_list = target_list
selected_type = self.version_type_dropdown.currentText()
class DisplayListItem(QListWidgetItem):
def __init__(self, parent: QListWidget=None, display_text: str=None, raw_value: str=None):
QListWidgetItem.__init__(self, parent)
if not selected_type:
item.result_queue.put(None)
else:
item.result_queue.put(selected_type)
self.display_text = display_text
self.raw_value = raw_value
item.wait_event.set()
self.setText(self.display_text)
@Slot(object)
def get_selected_version(self, item: SignalTransferItem):
if not isinstance(item, SignalTransferItem):
raise TypeError("Item is not a SignalTransferItem")
def get_selected_item(self, request: GetSelectedItem):
if not isinstance(request, self.GetSelectedItem):
raise TypeError("Item is not a GetSelectedItem")
selected_item = self.version_list.currentItem()
target = request.target_list
if selected_item is None:
item.result_queue.put(None)
if isinstance(target, QComboBox):
selected_text = target.currentText()
elif isinstance(target, QListWidget):
item = target.currentItem()
if isinstance(item, self.DisplayListItem):
selected_text = item.raw_value
else:
selected_text = None
else:
item.result_queue.put(selected_item.text())
raise TypeError("Target list is not a QListWidget or QComboBox")
item.wait_event.set()
if not selected_text:
request.result_queue.put(None)
else:
request.result_queue.put(selected_text)
request.wait_event.set()
def update_type_dropdown(self, types: list):
# Move release to top
@@ -218,63 +301,328 @@ class CreateProfilePage(QWidget):
if len(self.version_type_dropdown.currentText()) == 0:
self.version_type_dropdown.setCurrentIndex(0)
def update_version_dropdown(self, versions: list):
ids = []
for version in versions:
ver_id = version.get("id", None)
if ver_id is not None:
ids.append(ver_id)
def update_version_list(self, request: UpdateListRequest):
version_list = request.target_list
self.version_list.addItems(ids)
if not isinstance(version_list, QListWidget | QComboBox):
raise TypeError("Target list is not a QListWidget or QComboBox")
selected_item = self.version_list.currentItem()
for version in request.items:
ver_id = version.get("id", None) or version.get("version", None)
stable = version.get("stable", None)
if selected_item is None and len(ids) > 0:
self.version_list.setCurrentRow(0)
if not ver_id:
self.app.logger.warning("Provided version {} missing ID.".format(version))
display_text = ver_id
if stable is not None and isinstance(stable, bool):
display_text = f"{ver_id}"+(" (stable)" if stable else "")
item = self.DisplayListItem(
parent=request.target_list,
display_text=display_text,
raw_value=ver_id,
)
version_list.addItem(item)
def update_mod_loader_list(self, loaders: dict):
for loader_name, value in loaders.items():
self.mod_loader_version_list.addItem(loader_name)
#
# Thread Starter
#
def init_all(self):
self.start_load_type_list(callback=self.start_load_version_list)
self.start_load_type_list()
self.start_load_mod_loader_list()
def start_load_type_list(self, callback=None):
thread = threading.Thread(target=self.load_type_list, args=(callback,))
def start_load_type_list(self):
thread = threading.Thread(target=self.load_type_list)
thread.start()
def start_load_version_list(self):
thread = threading.Thread(target=self.load_version_list)
thread.start()
def start_load_mod_loader_list_and_version(self):
pass
def start_load_mod_loader_list(self):
thread = threading.Thread(target=self.load_mod_loader_list)
thread.start()
def start_load_mod_loader_version(self):
thread = threading.Thread(target=self.load_mod_loader_version)
thread.start()
#
# Processor
#
def create_profile(self):
request = self.GetSelectedItem(
target_list=self.version_list,
)
self.signals.get_selected_item.emit(request)
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected version.",
)
return
minecraft_version = request.result()
if not minecraft_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a version before creating profile.",
)
return
loader_request = self.GetSelectedItem(
target_list=self.mod_loader_dropdown,
)
self.signals.get_selected_item.emit(loader_request)
if not loader_request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected mod loader.",
)
return
loader_name = loader_request.result()
if loader_name not in self.loader_list:
self.widget_mgr.signal.show_error_message.emit(
"Loader {} not found.".format(loader_name),
)
return
loader = self.loader_list[loader_name]
profile_create_handler: Callable[[str, str, str | None], None] = loader.get("profileCreateHandler", None)
if profile_create_handler is None:
self.widget_mgr.signal.show_warning_message.emit(
"Profile creation for loader {} is not implemented.".format(loader_name),
)
return
loader_version_request = self.GetSelectedItem(
target_list=self.mod_loader_version_list,
)
self.signals.get_selected_item.emit(loader_version_request)
if not loader_version_request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected mod loader version.",
)
return
loader_version = loader_version_request.result()
if not loader_version and loader_version is not None:
loader_version = None
profile_name, accepted = QInputDialog.getText(
self,
"Create Profile",
"Profile name:",
text=f"Minecraft {minecraft_version}",
)
if not accepted:
return
profile_name = profile_name.strip()
if not profile_name:
self.widget_mgr.signal.show_warning_message.emit(
"Profile name cannot be empty.",
)
return
# Call handler
if callable(profile_create_handler):
profile_create_handler(profile_name, minecraft_version, loader_version)
def handle_vanilla_profile_create(self, profile_name: str, version: str, loader_version: None=None, switch_to_current=False):
"""
Handle vanilla profile creation.
:param profile_name:
:param version:
:param loader_version:
:param switch_to_current: Switch new profile to current profile.
:return:
"""
manifest = self.manifest_mgr.get_cached_version_manifest(version)
if not manifest:
self.widget_mgr.signal.show_warning_message.emit(
"Unable to create profile due to manifest fetch failure.",
)
return
version_id: str = manifest.get("id")
version_type = manifest.get(
"type",
self.version_type_dropdown.currentText() or "custom",
)
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.",
)
return
request = CreateProfileRequest(
name=profile_name,
version_id=version_id,
version_type=version_type,
)
# Switch to current profile and save
profile_id = self.app.profile_manager.create_profile(request)
if profile_id is None:
return
if switch_to_current:
if not self.app.profile_manager.set_current_profile(profile_id):
return
if not self.app.profile_manager.save():
return
self.widget_mgr.signal.show_info_message.emit(
f"Profile {profile_id} successfully created.",
)
def handle_fabric_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None, switch_to_current=False):
"""
Handle fabric profile creation.
:param profile_name:
:param minecraft_version:
:param loader_version:
:param switch_to_current:
:return:
"""
if not loader_version:
self.widget_mgr.signal.show_warning_message.emit(
"Selected a fabric loader version before creating profile.",
)
return
# Create vanilla manifest
vanilla_manifest = self.manifest_mgr.get_cached_version_manifest(
minecraft_version
)
if not vanilla_manifest:
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile due to vanilla manifest fetch failure.",
)
return
version_id = fabric.get_full_version_string(version=minecraft_version, loader_version=loader_version)
def fetch_loader_manifest(_version_id: str, ) -> tuple[dict, None]:
manifest = fabric.get_loader_manifest_from_loader_data(
minecraft_version,
{"version": loader_version},
)
return manifest, None
loader_manifest = self.manifest_mgr.get_cached_custom_version_manifest(
version_id, fetch_manifest_handler=fetch_loader_manifest,)
if not loader_manifest:
self.widget_mgr.signal.show_error_message.emit(
"Unable to create profile due to mod loader manifest fetch failure.",
)
return
loader_info = find_useful_part_in_mod_version_manifest(
loader_manifest
)
# Check mod info
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.",
)
return
if loader_info["inheritsFrom"] != minecraft_version:
self.app.logger.error(
f"Fabric manifest inherits from "
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.",
)
return
request = CreateProfileRequest(
name=profile_name,
version_id=version_id,
version_type=loader_info["type"] or vanilla_manifest["type"] or "custom",
)
profile_id = self.app.profile_manager.create_profile(
request
)
if profile_id is None:
return
if switch_to_current:
if not self.app.profile_manager.set_current_profile(profile_id):
return
if not self.app.profile_manager.save():
return
self.widget_mgr.signal.show_info_message.emit(
f"Profile {profile_id} with fabric loader successfully created.",
)
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.",
)
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.",
)
#
# Background Thread
#
def load_type_list(self, callback: Callable[None]=None):
# Cleanup
self.signals.clean_list.emit(self.version_type_dropdown)
def load_type_list(self):
manifest = self.manifest_mgr.get_cached_version_manifest()
if not manifest:
return
ver_types = get_available_version_types(manifest)
release_index = ver_types.index("release") if "release" in ver_types else None
self.signals.update_type_dropdown.emit(ver_types)
if callable(callback):
callback()
def load_version_list(self, callback: Callable[Callable[None] | None]=None):
# Cleanup
self.version_list.clear()
request = self.UpdateListRequest(
target_list=self.version_type_dropdown,
items=ver_types,
current_index=release_index,
clean=True,
block_signals=True,
complete_callback=self.start_load_version_list,
)
self.signals.update_list.emit(request)
def load_version_list(self):
# Get selected version type
request = SignalTransferItem()
self.signals.get_selected_type.emit(request)
request = self.GetSelectedItem(
target_list=self.version_type_dropdown
)
self.signals.get_selected_item.emit(request)
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
@@ -292,13 +640,115 @@ class CreateProfilePage(QWidget):
# Fetch (or read) manifest
manifest = self.manifest_mgr.get_cached_version_manifest()
if not manifest:
return
versions = get_all_versions(
manifest,
specified_type=selected_type
)
# Update list
self.update_version_dropdown(versions)
request = self.UpdateListRequest(
target_list=self.version_list,
items=versions,
clean=True,
current_index=0,
custom_handler=self.update_version_list
)
self.signals.update_list.emit(request)
if callable(callback):
callback(None)
def load_vanilla_version_list(self):
"""
A dummy port for load_mod_loader_version
:return:
"""
self.clean_list(self.mod_loader_version_list)
def load_fabric_version_list(self):
self.signals.clean_list.emit(self.mod_loader_version_list)
request = self.GetSelectedItem(
target_list=self.version_list
)
self.signals.get_selected_item.emit(request)
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"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.",
)
try:
loaders = fabric.get_version_support_loader_list(
selected_version
)
except (VersionManifestFetchException, VersionManifestException) as e:
self.widget_mgr.signal.show_error_message.emit(
"Failed to fetch version manifest: {}".format(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),
)
return
request = self.UpdateListRequest(
target_list=self.mod_loader_version_list,
items=loaders,
current_index=0,
custom_handler=self.update_version_list,
block_signals=True,
)
self.signals.update_list.emit(request)
def load_mod_loader_list(self):
loaders = [loader for loader in self.loader_list.keys()]
request = self.UpdateListRequest(
target_list=self.mod_loader_dropdown,
items=loaders,
current_index=0,
clean=True
)
self.signals.update_list.emit(request)
def load_mod_loader_version(self):
request = self.GetSelectedItem(
target_list=self.mod_loader_dropdown
)
self.signals.get_selected_item.emit(request)
if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit(
"Timeout while waiting for selected loader.",
)
return
loader_name = request.result()
if loader_name not in self.loader_list:
raise KeyError("Target loader {} not found".format(loader_name))
loader = self.loader_list[loader_name]
version_handler = loader.get("loadVersionMethod", None)
if not version_handler:
self.widget_mgr.signal.show_warning_message.emit(
"Selected loader \"{}\" is not implemented".format(loader_name),
)
return
if callable(version_handler):
version_handler()
else:
self.app.logger.warning(
"Select mod loader \"loadVersionMethod\" is not callable."
)