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
+141 -14
View File
@@ -1,15 +1,17 @@
import sys import sys
from _colorize import Theme
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from PySide6.QtCore import QSize from PySide6.QtCore import QSize
from PySide6.QtGui import QIcon, QPixmap from PySide6.QtGui import QIcon, QPixmap, QPalette, QColor
from PySide6.QtWidgets import QApplication, QStyle from PySide6.QtWidgets import QApplication, QStyle, QToolButton
from constant import LAUNCHER_VERSION from constant import LAUNCHER_VERSION
from core_lib.qt.hack import move_window_to_center, is_light_theme, \ from core_lib.qt.hack import move_window_to_center, is_light_theme, \
recolor_icon recolor_icon
from core_lib.qt import messagebox from core_lib.qt import messagebox
from core_lib.i18n import TranslationManager
from core_lib.launcher.object import Launcher as LauncherObject from core_lib.launcher.object import Launcher as LauncherObject
from manager.account import AccountManager from manager.account import AccountManager
from manager.game import GameManager from manager.game import GameManager
@@ -22,6 +24,8 @@ from window import LauncherMainWindow
class Launcher(QApplication, LauncherObject): class Launcher(QApplication, LauncherObject):
def __init__(self, logger, debug: bool = False): def __init__(self, logger, debug: bool = False):
super().__init__() super().__init__()
self._system_palette = QPalette(self.palette()) # theme
# logger # logger
self.logger = logger self.logger = logger
self.debug = debug self.debug = debug
@@ -41,6 +45,15 @@ class Launcher(QApplication, LauncherObject):
self.settings_manager.load() self.settings_manager.load()
else: else:
self.settings_manager.load_defaults().save() self.settings_manager.load_defaults().save()
general = self.settings_manager.get("general", GeneralSettings)
self.i18n = TranslationManager(
self.resources_dir / "translations",
general.language,
)
self.apply_theme()
self.profile_manager = ProfileManager(self, self.widget_manager) self.profile_manager = ProfileManager(self, self.widget_manager)
self.account_manager = AccountManager(self, self.widget_manager) self.account_manager = AccountManager(self, self.widget_manager)
self.game_manager = GameManager(self) self.game_manager = GameManager(self)
@@ -73,8 +86,8 @@ class Launcher(QApplication, LauncherObject):
if not path.exists() or not path.is_file(): if not path.exists() or not path.is_file():
messagebox.error( messagebox.error(
self.main_window, self.main_window,
"Resource Error", self.tr_text("Resource Error"),
"Missing icon file: {}".format(path) self.tr_text("Missing icon file: {path}", path=path)
) )
return icon return icon
@@ -89,19 +102,58 @@ class Launcher(QApplication, LauncherObject):
except Exception as e: except Exception as e:
messagebox.error( messagebox.error(
self.main_window, self.main_window,
"Resource Error", self.tr_text("Resource Error"),
"Failed to load icon {}: {}".format(path, e) self.tr_text("Failed to load icon {path}: {error}", path=path, error=e)
) )
return icon return icon
def tr_text(self, key: str, /, **values) -> str:
"""Translate a stable English source string for the active language."""
return self.i18n.translate(key, **values)
def apply_language(self, language: str) -> None:
if language != self.i18n.language:
self.i18n.set_language(language)
messagebox.info(None,"Launcher",
self.tr_text("To make all page apply new language."
" Is recommend to restart the launcher after change it."))
def set_reloadable_toolbutton_icon(
self,
button: QToolButton,
path: Path,
no_color_change: bool = False,
) -> None:
"""Set an icon and retain its source so theme changes can reload it."""
button.setProperty("themeIconPath", str(path))
button.setProperty("themeIconNoColorChange", no_color_change)
button.setIcon(self.get_icon(path, no_color_change))
def reload_toolbutton_icons(self) -> None:
for button in self.allWidgets():
if not isinstance(button, QToolButton):
continue
path = button.property("themeIconPath")
if not path:
continue
no_color_change = bool(
button.property("themeIconNoColorChange")
)
button.setIcon(
self.get_icon(Path(path), no_color_change)
)
def get_picture(self, path: Path, size: QSize, custom_error: str=None) -> QPixmap: def get_picture(self, path: Path, size: QSize, custom_error: str=None) -> QPixmap:
picture = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning).pixmap(size) picture = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning).pixmap(size)
if not path.exists() or not path.is_file(): if not path.exists() or not path.is_file():
messagebox.error( messagebox.error(
self.main_window, self.main_window,
"Resource Error", self.tr_text("Resource Error"),
"Missing icon file: {}".format(path) if not custom_error else custom_error self.tr_text("Missing icon file: {path}", path=path)
if not custom_error else custom_error
) )
return picture return picture
@@ -111,8 +163,8 @@ class Launcher(QApplication, LauncherObject):
except Exception as e: except Exception as e:
messagebox.error( messagebox.error(
self.main_window, self.main_window,
"Resource Error", self.tr_text("Resource Error"),
"Failed to load icon {}: {}".format(path, e) self.tr_text("Failed to load icon {path}: {error}", path=path, error=e)
) )
return picture return picture
@@ -167,8 +219,8 @@ class Launcher(QApplication, LauncherObject):
if not full_path.exists(): if not full_path.exists():
messagebox.error( messagebox.error(
None, None,
"Resource Error", self.tr_text("Resource Error"),
"Missing stylesheet file: {}".format(full_path), self.tr_text("Missing stylesheet file: {path}", path=full_path),
) )
return "" return ""
@@ -178,11 +230,86 @@ class Launcher(QApplication, LauncherObject):
except Exception as e: except Exception as e:
messagebox.error( messagebox.error(
self.main_window, self.main_window,
"Resource Error", self.tr_text("Resource Error"),
"Unable to apply QSS stylesheet {}: {}".format(full_path, e), self.tr_text(
"Unable to apply QSS stylesheet {path}: {error}",
path=full_path,
error=e,
),
) )
return "" return ""
def get_main_window(self): def get_main_window(self):
return self.main_window return self.main_window
def apply_theme(self, theme: Theme | None=None):
if not theme:
appearance = self.settings_manager.get("appearance", AppearanceSettings)
theme = appearance.theme
if theme == "system":
palette = self._system_palette
elif theme == "dark":
palette = self._dark_palette()
elif theme == "light":
palette = self._light_palette()
else:
raise ValueError(f"Unsupported theme: {theme}")
self.setPalette(palette)
for widget in self.allWidgets():
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
if hasattr(self, "main_window"):
self.reload_toolbutton_icons()
self.main_window.toolbar.update_all()
@staticmethod
def _light_palette() -> QPalette:
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#f5f5f5"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#eeeeee"))
palette.setColor(QPalette.ColorRole.Text, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Button, QColor("#ffffff"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#202020"))
palette.setColor(QPalette.ColorRole.Mid, QColor("#c5c5c5"))
palette.setColor(QPalette.ColorRole.Midlight, QColor("#e2e2e2"))
palette.setColor(QPalette.ColorRole.Dark, QColor("#aaaaaa"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#bf265e"))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
palette.setColor(
QPalette.ColorRole.PlaceholderText,
QColor("#707070"),
)
return palette
@staticmethod
def _dark_palette() -> QPalette:
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#202124"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#f1f3f4"))
palette.setColor(QPalette.ColorRole.Base, QColor("#17181a"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#292a2d"))
palette.setColor(QPalette.ColorRole.Text, QColor("#f1f3f4"))
palette.setColor(QPalette.ColorRole.Button, QColor("#292a2d"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#f1f3f4"))
palette.setColor(QPalette.ColorRole.Mid, QColor("#4a4c50"))
palette.setColor(QPalette.ColorRole.Midlight, QColor("#383a3e"))
palette.setColor(QPalette.ColorRole.Dark, QColor("#121315"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#bf265e"))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
palette.setColor(
QPalette.ColorRole.PlaceholderText,
QColor("#a0a4aa"),
)
return palette
+2 -2
View File
@@ -739,8 +739,8 @@ class AccountManager(QObject):
# Ask user before actually delete # Ask user before actually delete
answer = QMessageBox.question( answer = QMessageBox.question(
self.app.main_window, self.app.main_window,
"Delete all accounts", self.app.tr_text("Delete all accounts"),
"Delete every saved account? This cannot be undone.", self.app.tr_text("Delete every saved account? This cannot be undone."),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
) )
+451 -72
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -15,7 +16,7 @@ from PySide6.QtCore import QObject, Signal
from constant import CLIENT_ID, LAUNCHER_VERSION from constant import CLIENT_ID, LAUNCHER_VERSION
from core_lib.common.common import FileObject, download_multiple_files from core_lib.common.common import FileObject, download_multiple_files
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \ from core_lib.common.exception import JavaRuntimeException, VersionManifestFetchException, VersionManifestSaveException, \
NoSpecifiedVersionKeyException NoSpecifiedVersionKeyException
from core_lib.game.argument import ArgumentMappings, generate_launch_command from core_lib.game.argument import ArgumentMappings, generate_launch_command
from core_lib.game.asset import check_assets_exist, download_assets, find_correct_assets_dir from core_lib.game.asset import check_assets_exist, download_assets, find_correct_assets_dir
@@ -24,13 +25,22 @@ from core_lib.game.mod.mod_core import merge_manifest
from core_lib.game.version_info import fetch_and_save_version_manifest, get_specific_version_manifest_url_and_hash, \ from core_lib.game.version_info import fetch_and_save_version_manifest, get_specific_version_manifest_url_and_hash, \
save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \ save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \
LATEST_VERSION_ALIASES LATEST_VERSION_ALIASES
from core_lib.runtime.java import find_java_installations from core_lib.runtime.java import find_java_installations, get_java_version
from core_lib.runtime.mojang import download_java_runtimes, fetch_runtime_manifest, get_current_platform_runtime_key
from manager.account import AccountManager, LaunchAccount from manager.account import AccountManager, LaunchAccount
from manager.profile import LaunchProfile from manager.profile import LaunchProfile
from manager.widgets import SelectPathRequest, AskForRequest, SignalTransferItem from manager.widgets import SelectPathRequest, AskForRequest, SignalTransferItem
MAX_DOWNLOAD_ATTEMPTS = 3 MAX_DOWNLOAD_ATTEMPTS = 3
@dataclass(frozen=True)
class AvailableVersion:
id: str
display_name: str
version_type: str
source: str
inherits_from: str | None = None
class ManifestManager: class ManifestManager:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
@@ -305,10 +315,112 @@ class ManifestManager:
is_snapshot=version_id == "latest-snapshot", is_snapshot=version_id == "latest-snapshot",
) )
def list_available_profile_versions(self) -> list[AvailableVersion]:
result: list[AvailableVersion] = []
root_manifest = self.get_cached_version_manifest()
vanilla_ids: set[str] = set()
# For vanilla version
for version in (root_manifest or {}).get("versions", []):
version_id = version.get("id")
if not version_id:
continue
vanilla_ids.add(version_id)
result.append(
AvailableVersion(
id=version_id,
display_name=f"Minecraft {version_id}",
version_type=version.get("type") or "custom",
source="vanilla",
)
)
# Official launcher aliases are valid vanilla profile versions too.
for alias_id, alias in LATEST_VERSION_ALIASES.items():
result.append(
AvailableVersion(
id=alias_id,
display_name=alias.get("displayName", alias_id),
version_type=(
"snapshot" if alias_id == "latest-snapshot" else "release"
),
source="vanilla",
)
)
# Locally created modded and custom versions.
versions_dir = Path(self.app.work_dir, "versions")
for manifest_path in versions_dir.glob("*/*.json"):
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
self.app.logger.warning(
f"Unable to read local version manifest {manifest_path}: {exc}"
)
continue
version_id = manifest.get("id")
if not version_id or version_id in vanilla_ids:
continue
inherits_from = manifest.get("inheritsFrom")
result.append(
AvailableVersion(
id=version_id,
display_name=(
f"{version_id} - Minecraft {inherits_from}"
if inherits_from else version_id
),
version_type=manifest.get("type") or "custom",
source="modded" if inherits_from else "custom",
inherits_from=inherits_from,
)
)
# Keep the first entry for each ID. The root manifest takes precedence
# over locally cached copies of vanilla manifests.
unique: dict[str, AvailableVersion] = {}
for version in result:
unique.setdefault(version.id, version)
return list(unique.values())
@dataclass(frozen=True)
class JavaRuntime:
id: str
major_version: int
raw_version: str
executable: Path
home_dir: Path
source: Literal["system", "managed", "custom"]
component: str | None = None
valid: bool = True
def to_dict(self) -> dict:
return {
"id": self.id,
"major": self.major_version,
"raw": self.raw_version,
"executable": self.executable.as_posix(),
"homeDir": self.home_dir.as_posix(),
"source": self.source,
"component": self.component,
}
@dataclass(frozen=True)
class InstallableJavaRuntime:
component: str
major_version: int | None
raw_version: str | None
released: str | None = None
class JavaManager: class JavaManager:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.widget_mgr = app.widget_manager self.widget_mgr = app.widget_manager
self.runtimes: list[JavaRuntime] = self._read_runtimes()
# #
# JVM Management # JVM Management
@@ -317,81 +429,210 @@ class JavaManager:
def jvm_settings_path(self) -> Path: def jvm_settings_path(self) -> Path:
return Path(self.app.work_dir) / "jvms.json" return Path(self.app.work_dir) / "jvms.json"
def _find_and_save_jvm_search_result(self): @staticmethod
installations, errors = find_java_installations() def _runtime_from_dict(data: dict) -> JavaRuntime:
if not installations: """
self.widget_mgr.signal.show_warning_message.emit( Convert runtime data to JavaRuntime object
"There are no Java installations available in you system.\n" :param data:
) :return:
"""
executable = Path(data["executable"])
home_dir = Path(data.get("homeDir") or data.get("home_dir") or executable.parent.parent)
return JavaRuntime(
id=str(data.get("id") or uuid4()),
major_version=int(data["major"]),
raw_version=str(data.get("raw") or data["major"]),
executable=executable,
home_dir=home_dir,
source=data.get("source") or "system",
component=data.get("component"),
valid=executable.is_file(),
)
def _read_runtimes(self) -> list[JavaRuntime]:
"""
Read existing runtime data from jvms.json
:return:
"""
if not self.jvm_settings_path.is_file():
return []
try:
data = json.loads(self.jvm_settings_path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise ValueError("JVM settings root must be a list")
return [self._runtime_from_dict(item) for item in data if isinstance(item, dict)]
except (OSError, ValueError, KeyError, TypeError) as exc:
self.app.logger.warning(f"Unable to read JVM settings: {exc}")
return [] return []
if errors: def save_runtimes(self) -> None:
request = AskForRequest("Java Virtual Machine Finder", message = ( """
"Unable to detect below java installation version:\n" Save memory runtimes to jvms.json
)) :return:
request.details = "\n".join(path.as_posix() for path in errors) """
request.use_plain_text = True
self.widget_mgr.signal.askyesno.emit(request)
self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True) self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True)
try: temporary = self.jvm_settings_path.with_suffix(".json.tmp")
with self.jvm_settings_path.open("w") as file: temporary.write_text(
json.dump(installations, file, indent=4) json.dumps([runtime.to_dict() for runtime in self.runtimes], indent=4),
encoding="utf-8",
)
return installations temporary.replace(self.jvm_settings_path)
except Exception as e:
self.widget_mgr.signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Settings Error",
"message": "An error occurred while attempting to save jvm settings. Please try again.",
}
)
def _read_exist_jvm_settings(self): @staticmethod
try: def inspect_runtime(executable: str | Path,
return json.loads(self.jvm_settings_path.read_text()) *,
except Exception as e: source: Literal["system", "managed", "custom"] = "custom",
self.widget_mgr.signal.show_and_print_error_message.emit( component: str | None = None,
{ runtime_id: str | None = None,
"error": traceback.format_exception(e), ) -> JavaRuntime:
"title": "Settings Error", """
"message": "An error occurred while reading jvm settings.", Check runtime version
} :param executable:
) :param source:
:param component:
:param runtime_id:
:return:
runtime: JavaRuntime
"""
executable = Path(executable).expanduser().resolve()
if not executable.is_file():
raise JavaRuntimeException(f"Java executable does not exist: {executable}")
major, raw = get_java_version(executable)
return JavaRuntime(
id=runtime_id or uuid4().hex,
major_version=major,
raw_version=raw,
executable=executable,
home_dir=executable.parent.parent,
source=source,
component=component,
)
def discover_runtimes(self, extra_install_patterns: list | None = None) -> tuple[list[JavaRuntime], list[Path]]:
"""
Find available runtimes
:param extra_install_patterns:
:return:
discovered: list[JavaRuntime]
errors: list[Path]
"""
installations, errors = find_java_installations(extra_install_patterns)
known_ids = {runtime.executable.resolve(): runtime.id for runtime in self.runtimes}
discovered = []
for item in installations:
executable = Path(item["executable"]).resolve()
discovered.append(self._runtime_from_dict({
**item,
"id": known_ids.get(executable, uuid4().hex),
"source": "system",
}))
return discovered, errors
def refresh_runtimes(self, extra_install_patterns: list | None = None) -> tuple[list[JavaRuntime], list[Path]]:
"""
Discover available runtimes and save
:param extra_install_patterns:
:return:
runtimes: list[JavaRuntime]
errors: list[Path]
"""
discovered, errors = self.discover_runtimes(extra_install_patterns)
retained = [runtime for runtime in self.runtimes if runtime.source != "system"]
by_executable = {runtime.executable.resolve(): runtime for runtime in retained}
for runtime in discovered:
by_executable.setdefault(runtime.executable.resolve(), runtime)
self.runtimes = list(by_executable.values())
self.save_runtimes()
return list(self.runtimes), errors
def register_runtime(self, executable: str | Path) -> JavaRuntime:
"""
Register runtime to manage's runtimes
:param executable:
:return:
"""
candidate = Path(executable).expanduser().resolve()
for runtime in self.runtimes:
if runtime.executable.resolve() == candidate:
return runtime
runtime = self.inspect_runtime(candidate, source="custom")
self.runtimes.append(runtime)
self.save_runtimes()
return runtime
@staticmethod
def _runtime_major_version(raw_version: str | None) -> int | None:
if not raw_version:
return None return None
match = re.match(r"^(?:1\.)?(\d+)", raw_version)
return int(match.group(1)) if match else None
def get_or_cache_jvm_settings(self) -> list: def list_installable_runtimes(self) -> list[InstallableJavaRuntime]:
if self.jvm_settings_path.exists(): """Return Mojang components with the Java version carried by each one."""
exists = self._read_exist_jvm_settings() manifest = fetch_runtime_manifest()
platform_key = get_current_platform_runtime_key()
if not platform_key:
return []
platform_runtimes = manifest.get(platform_key, {})
result = []
for component, versions in platform_runtimes.items():
if not isinstance(versions, list) or not versions:
continue
latest = versions[0] if isinstance(versions[0], dict) else {}
version = latest.get("version") or {}
raw_version = version.get("name")
result.append(InstallableJavaRuntime(
component=component,
major_version=self._runtime_major_version(raw_version),
raw_version=raw_version,
released=version.get("released"),
))
return sorted(result, key=lambda item: (item.major_version or -1, item.component), reverse=True)
if exists: def list_installable_components(self) -> list[str]:
return exists """Compatibility API returning component names only."""
return [runtime.component for runtime in self.list_installable_runtimes()]
return self._find_and_save_jvm_search_result() def unregister_runtime(self, runtime_id: str) -> bool:
"""
Remove runtime from manage's runtimes
:param runtime_id:
:return:
"""
original_count = len(self.runtimes)
self.runtimes = [runtime for runtime in self.runtimes if runtime.id != runtime_id]
changed = len(self.runtimes) != original_count
if changed:
self.save_runtimes()
return changed
def get_or_cache_jvm_settings(self) -> list[dict]:
if not self.runtimes:
self.refresh_runtimes()
return [runtime.to_dict() for runtime in self.runtimes]
def get_support_runtime_jvm_executable(self, major_version: int | str, no_error: bool=False) -> str | None: def get_support_runtime_jvm_executable(self, major_version: int | str, no_error: bool=False) -> str | None:
result = self.get_or_cache_jvm_settings() runtime = self.resolve_runtime(major_version=int(major_version))
if runtime:
if not result: return runtime.executable.as_posix()
return None
for item in result:
version = item.get("major", None)
executable = item.get("executable", None)
try:
version = str(version)
major_version = str(major_version)
except Exception as e:
self.app.logger.warning(
f"Unable to convert version {version} to string: {e}"
)
if major_version == version and executable:
return executable
if not no_error: if not no_error:
self.widget_mgr.signal.show_error_message.emit( self.widget_mgr.signal.show_error_message.emit(
@@ -400,6 +641,81 @@ class JavaManager:
return None return None
def list_runtimes(self, *, refresh: bool = False) -> list[JavaRuntime]:
if refresh:
self.refresh_runtimes()
return list(self.runtimes)
def resolve_runtime(self, *, manifest: dict | None = None, major_version: int | None = None,
preferred_runtime_id: str | None = None) -> JavaRuntime | None:
java_requirement = (manifest or {}).get("javaVersion", {})
required_major = major_version or java_requirement.get("majorVersion")
if required_major is None:
return None
runtimes = self.list_runtimes()
if not runtimes:
runtimes, _ = self.refresh_runtimes()
if preferred_runtime_id:
preferred = next((item for item in runtimes if item.id == preferred_runtime_id), None)
if preferred and preferred.valid and preferred.major_version == int(required_major):
return preferred
source_order = {"managed": 0, "custom": 1, "system": 2}
matches = [item for item in runtimes if item.valid and item.major_version == int(required_major)]
return min(matches, key=lambda item: source_order[item.source], default=None)
@property
def launcher_runtime_dir(self) -> Path:
return Path(self.app.work_dir) / "runtimes"
def install_runtime(self, component: str, *, progress_callback=None, redownload_callback=None) -> JavaRuntime:
destination = self.launcher_runtime_dir / component
download_java_runtimes(
component,
destination,
progress_callback=progress_callback,
redownload_callback=redownload_callback,
)
executable_name = "java.exe" if sys.platform == "win32" else "java"
candidates = sorted(destination.rglob(executable_name))
if not candidates:
raise JavaRuntimeException(f"Installed runtime has no {executable_name}: {destination}")
runtime = self.inspect_runtime(candidates[0], source="managed", component=component)
self.runtimes = [item for item in self.runtimes if item.executable.resolve() != runtime.executable.resolve()]
self.runtimes.append(runtime)
self.save_runtimes()
return runtime
def ensure_runtime(self, manifest: dict, *, auto_install: bool = False, progress_callback=None,
redownload_callback=None) -> JavaRuntime:
runtime = self.resolve_runtime(manifest=manifest)
if runtime:
return runtime
requirement = manifest.get("javaVersion") or {}
component = requirement.get("component")
major = requirement.get("majorVersion")
if not auto_install or not component:
raise JavaRuntimeException(f"No compatible Java runtime found (required major version: {major})")
runtime = self.install_runtime(
component,
progress_callback=progress_callback,
redownload_callback=redownload_callback,
)
if major is not None and runtime.major_version != int(major):
raise JavaRuntimeException(
f"Installed Java {runtime.major_version}, but Minecraft requires Java {major}"
)
return runtime
class GameFileDownloadRequest(SignalTransferItem): class GameFileDownloadRequest(SignalTransferItem):
def __init__(self, job_name: Literal["download_client", "download_libraries", "download_assets"], def __init__(self, job_name: Literal["download_client", "download_libraries", "download_assets"],
args: tuple): args: tuple):
@@ -650,6 +966,7 @@ class LaunchResult:
process: subprocess.Popen | None = None process: subprocess.Popen | None = None
error_message: str | None = None error_message: str | None = None
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
fail_callback: Callable | None = None
@property @property
def success(self) -> bool: def success(self) -> bool:
@@ -657,6 +974,8 @@ class LaunchResult:
def fail(self, message: str) -> "LaunchResult": def fail(self, message: str) -> "LaunchResult":
self.error_message = message self.error_message = message
if callable(self.fail_callback):
self.fail_callback()
return self return self
def add_warning(self, message: str) -> None: def add_warning(self, message: str) -> None:
@@ -680,6 +999,11 @@ class LaunchManager:
def launch(self, profile: LaunchProfile) -> LaunchResult: def launch(self, profile: LaunchProfile) -> LaunchResult:
result = LaunchResult() result = LaunchResult()
def fail_callback():
self.profile_failed(profile.profile_id)
result.fail_callback = fail_callback
try: try:
version_id = profile.version_id version_id = profile.version_id
version_id = self.manifest_mgr.resolve_version_alias(version_id) version_id = self.manifest_mgr.resolve_version_alias(version_id)
@@ -815,11 +1139,37 @@ class LaunchManager:
"Unable to parse arguments because data are invalid." "Unable to parse arguments because data are invalid."
) )
# Use found java runtime (if available) # Resolve a compatible runtime, then offer the Mojang runtime from
java_path = self.java_mgr.get_support_runtime_jvm_executable( # the version manifest before falling back to a custom executable.
java_major_version, runtime = self.java_mgr.resolve_runtime(manifest=version_manifest)
no_error=True java_path = runtime.executable.as_posix() if runtime else None
)
if not java_path:
component = java_version.get("component")
if component:
install_request = AskForRequest(
"Java Runtime Required",
f"Minecraft {version_id} requires Java {java_major_version}. "
f"Download the Mojang runtime now?",
)
self.widget_mgr.signal.askyesno.emit(install_request)
if install_request.wait(120) and install_request.result():
callback, task_id = self.manifest_mgr.create_download_progress_callback(
f"Installing Java {java_major_version}"
)
try:
runtime = self.java_mgr.ensure_runtime(
version_manifest,
auto_install=True,
progress_callback=callback,
redownload_callback=self.game_file_mgr.redownload_files,
)
except Exception as exc:
self.widget_mgr.download_signal.failed.emit(task_id, str(exc))
self.app.logger.warning(f"Automatic Java installation failed: {exc}")
else:
self.widget_mgr.download_signal.finished.emit(task_id)
java_path = runtime.executable.as_posix()
if not java_path: if not java_path:
# Ask for a support java version. If not, use system environment java # Ask for a support java version. If not, use system environment java
@@ -840,10 +1190,19 @@ class LaunchManager:
java_path = request.result() java_path = request.result()
if java_path: if java_path:
try:
runtime = self.java_mgr.register_runtime(java_path)
except JavaRuntimeException as exc:
return result.fail(f"The selected Java executable is invalid: {exc}")
if runtime.major_version != int(java_major_version):
return result.fail(
f"Minecraft requires Java {java_major_version}, but the selected executable is "
f"Java {runtime.major_version}."
)
java_path = runtime.executable.as_posix()
self.app.logger.debug(f"Use specified java executable path: {java_path}") self.app.logger.debug(f"Use specified java executable path: {java_path}")
else: else:
self.app.logger.debug("Use system environment java.") return result.fail(f"Java {java_major_version} is required to launch this version.")
java_path = "java"
self.app.logger.debug(f"Java executable path is: {java_path}") self.app.logger.debug(f"Java executable path is: {java_path}")
@@ -913,12 +1272,32 @@ class LaunchManager:
or profile_id in self.launched_profiles or profile_id in self.launched_profiles
) )
def profile_failed(self, profile_id: str) -> None:
try:
self.launched_profiles.remove(profile_id)
except ValueError:
pass
try:
self.launching_profiles.remove(profile_id)
except ValueError:
pass
self.app.logger.debug(
f"Profile {profile_id} exited."
)
def profile_finished(self, profile_id: str, exit_code: int) -> None: def profile_finished(self, profile_id: str, exit_code: int) -> None:
try: try:
self.launched_profiles.remove(profile_id) self.launched_profiles.remove(profile_id)
except ValueError: except ValueError:
pass pass
try:
self.launching_profiles.remove(profile_id)
except ValueError:
pass
self.app.logger.debug( self.app.logger.debug(
f"Profile {profile_id} exited with code {exit_code}." f"Profile {profile_id} exited with code {exit_code}."
) )
+30 -23
View File
@@ -25,9 +25,10 @@ from manager.settings_models import AccountSettings
class AccountWidget(QWidget): 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,): /, delete_account_callback=None, check_account_callback=None, switch_account_callback=None,):
super().__init__(parent) super().__init__(parent)
self.app = app
# Layout # Layout
self.layout = QHBoxLayout(self) self.layout = QHBoxLayout(self)
@@ -57,10 +58,10 @@ class AccountWidget(QWidget):
def show_right_click_menu(self, position): def show_right_click_menu(self, position):
menu = QMenu(self) menu = QMenu(self)
switch_action = menu.addAction("Switch to this account") switch_action = menu.addAction(self.app.tr_text("Switch to this account"))
check_action = menu.addAction("Check login status") check_action = menu.addAction(self.app.tr_text("Check login status"))
menu.addSeparator() menu.addSeparator()
delete_action = menu.addAction("Delete account") delete_action = menu.addAction(self.app.tr_text("Delete account"))
selected = menu.exec(self.mapToGlobal(position)) selected = menu.exec(self.mapToGlobal(position))
if selected == delete_action and callable(self.delete_account_callback): 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=""): def __init__(self, app, parent=None, code="", error_message=""):
super().__init__(parent) super().__init__(parent)
self.app = app self.app = app
self.setWindowTitle("Microsoft Login") self.setWindowTitle(self.app.tr_text("Microsoft Login"))
self.setMinimumWidth(440) self.setMinimumWidth(440)
self.setMinimumHeight(380) self.setMinimumHeight(380)
self.resize(440, 420) self.resize(440, 420)
@@ -86,9 +87,9 @@ class LoginDialog(QDialog):
self.layout.setSpacing(14) self.layout.setSpacing(14)
# Top widgets # 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.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.setObjectName("helpText")
self.help_label.setWordWrap(True) self.help_label.setWordWrap(True)
@@ -106,9 +107,9 @@ class LoginDialog(QDialog):
self.error_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.error_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
# Button # 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.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.open_browser_button.setObjectName("openBrowserButton")
self.center_layout = QVBoxLayout() self.center_layout = QVBoxLayout()
@@ -142,12 +143,12 @@ class LoginDialog(QDialog):
def copy_code(self): def copy_code(self):
QApplication.clipboard().setText(self.code) 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) self.copy_code_button.setEnabled(False)
QTimer.singleShot(1200, self._reset_copy_button) QTimer.singleShot(1200, self._reset_copy_button)
def _reset_copy_button(self): 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) self.copy_code_button.setEnabled(True)
def set_error_message(self, message): def set_error_message(self, message):
@@ -173,9 +174,9 @@ class AccountPage(QWidget):
# Widgets # Widgets
self.account_list = QListWidget(self) self.account_list = QListWidget(self)
self.add_account_button = QPushButton("Add Account") self.add_account_button = QPushButton(self.app.tr_text("Add Account"))
self.refresh_button = QPushButton("Refresh all") self.refresh_button = QPushButton(self.app.tr_text("Refresh all"))
self.delete_all_button = QPushButton("Delete All") self.delete_all_button = QPushButton(self.app.tr_text("Delete All"))
# Button click handle # Button click handle
self.add_account_button.clicked.connect(self.start_login_page) 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 # Add suggestion to account list if no account available
if not accounts: 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.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
empty_item.setFlags(Qt.ItemFlag.NoItemFlags) empty_item.setFlags(Qt.ItemFlag.NoItemFlags)
self.account_list.addItem(empty_item) self.account_list.addItem(empty_item)
@@ -258,9 +259,10 @@ class AccountPage(QWidget):
head_size = QSize(32, 32) head_size = QSize(32, 32)
widget = AccountWidget( widget = AccountWidget(
self.app,
account.display_name, account.display_name,
self.account_manager.get_head_pixmap(account.avatar, head_size), 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, self,
delete_account_callback=lambda: self._delete_account(account.id), delete_account_callback=lambda: self._delete_account(account.id),
check_account_callback=lambda: self._show_account_status(account.id), check_account_callback=lambda: self._show_account_status(account.id),
@@ -310,9 +312,14 @@ class AccountPage(QWidget):
QMessageBox.information( QMessageBox.information(
self, self,
"Account status", self.app.tr_text("Account status"),
f"{account.display_name} — Minecraft profile " self.app.tr_text(
f"{'available' if account.minecraft_uuid else 'not available'}", "{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): def _switch_account(self, account_id: str):
@@ -336,7 +343,7 @@ class AccountPage(QWidget):
# Create login dialog # Create login dialog
self.add_account_button.setEnabled(False) self.add_account_button.setEnabled(False)
self.login_dialog = LoginDialog(parent=self, app=self.app) 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.copy_code_button.setEnabled(False)
self.login_dialog.open_browser_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.open_browser_button.clicked.connect(self.open_sign_in_page)
@@ -351,7 +358,7 @@ class AccountPage(QWidget):
if self.login_dialog is not None: if self.login_dialog is not None:
self.login_dialog.help_label.setText( 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.set_code(device.get("user_code", ""))
self.login_dialog.copy_code_button.setEnabled(True) self.login_dialog.copy_code_button.setEnabled(True)
@@ -378,11 +385,11 @@ class AccountPage(QWidget):
def _login_failed(self, message: str): def _login_failed(self, message: str):
if self.login_dialog is not None: if self.login_dialog is not None:
self.login_dialog.set_error_message(message) 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.copy_code_button.setEnabled(False)
self.login_dialog.open_browser_button.setEnabled(False) self.login_dialog.open_browser_button.setEnabled(False)
else: 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): def _login_finished(self):
self.add_account_button.setEnabled(True) 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.game import ManifestManager, GameManager
from manager.profile import CreateProfileRequest from manager.profile import CreateProfileRequest
from manager.widgets import SignalTransferItem, WidgetManager from manager.widgets import SignalTransferItem, WidgetManager
from pages.install_java_dialog import InstallJavaDialog
class CreateProfilePage(QWidget): class CreateProfilePage(QWidget):
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
@@ -33,7 +34,7 @@ class CreateProfilePage(QWidget):
self.operate_layout = QHBoxLayout() self.operate_layout = QHBoxLayout()
# Some items # 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 = QComboBox()
self.version_type_dropdown.setObjectName("Dropdown") 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")) icon = self.app.get_icon(Path(self.app.icon_dir, "mod_loader_temp.png"))
pixmap = icon.pixmap(QSize(100, 100)) pixmap = icon.pixmap(QSize(100, 100))
self.mod_loader_icon_label.setPixmap(pixmap) 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 = QComboBox()
self.mod_loader_dropdown.setObjectName("Dropdown") self.mod_loader_dropdown.setObjectName("Dropdown")
@@ -50,9 +51,7 @@ class CreateProfilePage(QWidget):
self.mod_config_layout = QHBoxLayout() self.mod_config_layout = QHBoxLayout()
self.mod_config_fields_layout = QVBoxLayout() self.mod_config_fields_layout = QVBoxLayout()
self.mod_loader_version_label = QLabel( self.mod_loader_version_label = QLabel(self.app.tr_text("Mod Loader Version"))
"Mod Loader Version"
)
self.mod_loader_version_list = QListWidget() self.mod_loader_version_list = QListWidget()
self.mod_loader_version_list.setObjectName( self.mod_loader_version_list.setObjectName(
@@ -60,23 +59,27 @@ class CreateProfilePage(QWidget):
) )
# Version list # Version list
self.version_list_type_label = QLabel("Version: Vanilla") self.version_list_type_label = QLabel(self.app.tr_text("Version: Vanilla"))
self.version_list_label = QLabel("Select a version to continue:") self.version_list_label = QLabel(self.app.tr_text("Select a version to continue:"))
self.version_list = QListWidget() self.version_list = QListWidget()
self.version_list.setObjectName("ListWidget") self.version_list.setObjectName("ListWidget")
# Button # 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.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.refresh_button.setObjectName("Button")
self.install_java_button = QPushButton(self.app.tr_text("Install Java"))
self.install_java_button.setObjectName("Button")
# Bind widget # Bind widget
self.main_layout.addWidget(self.version_list_label) self.main_layout.addWidget(self.version_list_label)
self.main_layout.addWidget(self.version_list) self.main_layout.addWidget(self.version_list)
self.operate_layout.addWidget(self.version_list_type_label) self.operate_layout.addWidget(self.version_list_type_label)
self.operate_layout.addStretch() self.operate_layout.addStretch()
self.operate_layout.addWidget(self.install_java_button)
self.operate_layout.addWidget(self.refresh_button) self.operate_layout.addWidget(self.refresh_button)
self.operate_layout.addWidget(self.create_profile_button) self.operate_layout.addWidget(self.create_profile_button)
@@ -142,6 +145,7 @@ class CreateProfilePage(QWidget):
# Button event # Button event
self.create_profile_button.clicked.connect(self.create_profile) 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 # Update version list while current type is changed
self.version_type_dropdown.currentTextChanged.connect( self.version_type_dropdown.currentTextChanged.connect(
@@ -406,7 +410,7 @@ class CreateProfilePage(QWidget):
if not request.wait(10): if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -414,7 +418,7 @@ class CreateProfilePage(QWidget):
if not minecraft_version: if not minecraft_version:
self.widget_mgr.signal.show_warning_message.emit( 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 return
@@ -425,7 +429,7 @@ class CreateProfilePage(QWidget):
if not loader_request.wait(10): if not loader_request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -433,7 +437,7 @@ class CreateProfilePage(QWidget):
if loader_name not in self.loader_list: if loader_name not in self.loader_list:
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -442,7 +446,10 @@ class CreateProfilePage(QWidget):
if profile_create_handler is None: if profile_create_handler is None:
self.widget_mgr.signal.show_warning_message.emit( 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 return
@@ -453,7 +460,7 @@ class CreateProfilePage(QWidget):
if not loader_version_request.wait(10): if not loader_version_request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -464,8 +471,8 @@ class CreateProfilePage(QWidget):
profile_name, accepted = QInputDialog.getText( profile_name, accepted = QInputDialog.getText(
self, self,
"Create Profile", self.app.tr_text("Create Profile"),
"Profile name:", self.app.tr_text("Profile name:"),
text=f"Minecraft {minecraft_version}", text=f"Minecraft {minecraft_version}",
) )
@@ -475,7 +482,7 @@ class CreateProfilePage(QWidget):
profile_name = profile_name.strip() profile_name = profile_name.strip()
if not profile_name: if not profile_name:
self.widget_mgr.signal.show_warning_message.emit( self.widget_mgr.signal.show_warning_message.emit(
"Profile name cannot be empty.", self.app.tr_text("Profile name cannot be empty."),
) )
return return
@@ -496,7 +503,7 @@ class CreateProfilePage(QWidget):
if not manifest: if not manifest:
self.widget_mgr.signal.show_warning_message.emit( 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 return
@@ -508,7 +515,7 @@ class CreateProfilePage(QWidget):
if version_id is None: if version_id is None:
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -531,7 +538,7 @@ class CreateProfilePage(QWidget):
return return
self.widget_mgr.signal.show_info_message.emit( 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): 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: if not loader_version:
self.widget_mgr.signal.show_warning_message.emit( 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 return
@@ -555,7 +562,7 @@ class CreateProfilePage(QWidget):
) )
if not vanilla_manifest: if not vanilla_manifest:
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -573,7 +580,7 @@ class CreateProfilePage(QWidget):
if not loader_manifest: if not loader_manifest:
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -585,7 +592,7 @@ class CreateProfilePage(QWidget):
if loader_info["id"] != version_id: if loader_info["id"] != version_id:
self.app.logger.error(f"Fabric manifest ID mismatch: expected {version_id}, got {loader_info["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( 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 return
@@ -595,7 +602,7 @@ class CreateProfilePage(QWidget):
f'{loader_info["inheritsFrom"]}, not {minecraft_version}.' f'{loader_info["inheritsFrom"]}, not {minecraft_version}.'
) )
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -620,20 +627,23 @@ class CreateProfilePage(QWidget):
return return
self.widget_mgr.signal.show_info_message.emit( 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): def handle_forge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None):
if not loader_version: if not loader_version:
self.widget_mgr.signal.show_warning_message.emit( 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): def handle_neoforge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None):
if not loader_version: if not loader_version:
self.widget_mgr.signal.show_warning_message.emit( 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): if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -674,7 +684,7 @@ class CreateProfilePage(QWidget):
if not selected_type: if not selected_type:
self.widget_mgr.signal.show_warning_message.emit( self.widget_mgr.signal.show_warning_message.emit(
"No type selected.", self.app.tr_text("No type selected."),
) )
return return
@@ -715,14 +725,14 @@ class CreateProfilePage(QWidget):
if not request.wait(10): if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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() selected_version = request.result()
if not selected_version: if not selected_version:
self.widget_mgr.signal.show_warning_message.emit( self.widget_mgr.signal.show_warning_message.emit(
"No version selected.", self.app.tr_text("No version selected."),
) )
try: try:
@@ -731,12 +741,17 @@ class CreateProfilePage(QWidget):
) )
except (VersionManifestFetchException, VersionManifestException) as e: except (VersionManifestFetchException, VersionManifestException) as e:
self.widget_mgr.signal.show_error_message.emit( 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 return
except Exception as e: except Exception as e:
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -768,7 +783,7 @@ class CreateProfilePage(QWidget):
if not request.wait(10): if not request.wait(10):
self.widget_mgr.signal.show_error_message.emit( 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 return
@@ -782,7 +797,10 @@ class CreateProfilePage(QWidget):
if not version_handler: if not version_handler:
self.widget_mgr.signal.show_warning_message.emit( 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 return
@@ -791,4 +809,22 @@ class CreateProfilePage(QWidget):
else: else:
self.app.logger.warning( self.app.logger.warning(
"Select mod loader \"loadVersionMethod\" is not callable." "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() self.bottom_layout = QHBoxLayout()
# Widgets # 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.setStyleSheet("font-size: 12px; font-weight: 600;")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 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.profile_selected.connect(self.select_profile)
self.profile_popup.closed.connect(self.refresh_profile_button) 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.setObjectName("launchButton")
launch_button.setMinimumWidth(120) launch_button.setMinimumWidth(120)
launch_button.setMinimumHeight(42) launch_button.setMinimumHeight(42)
@@ -258,7 +258,7 @@ class LaunchProfilePage(QWidget):
# self.profile_frame.setGraphicsEffect(shadow) # self.profile_frame.setGraphicsEffect(shadow)
# Header # Header
self.header_label = QLabel("Launch Profile") self.header_label = QLabel(self.app.tr_text("Launch Profile"))
self.header_label.setObjectName("headerLabel") self.header_label.setObjectName("headerLabel")
self.header_layout = QHBoxLayout(self.profile_overlay) self.header_layout = QHBoxLayout(self.profile_overlay)
@@ -322,16 +322,20 @@ class LaunchProfilePage(QWidget):
:return: :return:
""" """
if not result.success: 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( self.widget_mgr.signal.show_error_message.emit(
f"Unable to launch profile " self.app.tr_text(
f"{launch_profile.display_name}: " "Unable to launch profile {profile}: {error}",
f"{result.error_message}" profile=launch_profile.display_name,
error=result.error_message,
)
) )
self.status_label_cleanup()
return return
# Create log view # Create log view
log_window = ProcessLogWindow( log_window = ProcessLogWindow(
self.app,
result.process, result.process,
launch_profile.profile_id, launch_profile.profile_id,
title=( title=(
@@ -350,12 +354,17 @@ class LaunchProfilePage(QWidget):
self.process_log_windows.append(log_window) self.process_log_windows.append(log_window)
log_window.show() log_window.show()
self.status_label.setText("Launched!") self.status_label.setText(self.app.tr_text("Launched!"))
# Cleanup for status label # Cleanup for status label
self.status_label_cleanup()
def status_label_cleanup(self):
timer = QTimer(self) timer = QTimer(self)
timer.setSingleShot(True) 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) timer.start(3000)
def load_profiles(self): def load_profiles(self):
@@ -364,7 +373,7 @@ class LaunchProfilePage(QWidget):
if not profiles: if not profiles:
self.profile_button.setProperty("profile_id", None) 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() self.profile_button.version_label.clear()
return return
@@ -441,30 +450,30 @@ class LaunchProfilePage(QWidget):
if profile_id is None: if profile_id is None:
self.widget_mgr.signal.show_warning_message.emit( self.widget_mgr.signal.show_warning_message.emit(
"There is no profile is selected." self.app.tr_text("There is no profile is selected. Please create one first.")
" Please create one first."
) )
return 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) launch_profile = self.app.profile_manager.get_launch_profile(profile_id)
if launch_profile is None: 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 return
if self.launch_manager.is_profile_running(launch_profile.profile_id): if self.launch_manager.is_profile_running(launch_profile.profile_id):
request = AskForRequest( request = AskForRequest(
"Launch Request", self.app.tr_text("Launch Request"),
"Target profile {} is running. Are you sure you want to launch it again?".format( self.app.tr_text(
launch_profile.display_name "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) self.widget_mgr.signal.askyesno.emit(request)
if not request.wait(60): if not request.wait(60):
self.widget_mgr.signal.show_error_message.emit( 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 return
+229 -42
View File
@@ -6,23 +6,33 @@ from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox,
QVBoxLayout, QWidget, QVBoxLayout, QWidget, QMenu, QComboBox,
) )
from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest
from manager.game import AvailableVersion
from pages.add_modded_version import AddModdedVersionDialog
class ManageProfileItem(QPushButton): class ManageProfileItem(QPushButton):
selected = Signal(object) 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) super().__init__(parent)
self.app = app
self.profile = profile self.profile = profile
self.setObjectName("manageProfileItem") self.setObjectName("manageProfileItem")
self.setCheckable(True) self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMinimumHeight(64) self.setMinimumHeight(64)
# Callback
self.delete_callback = delete_callback
self.duplicate_callback = duplicate_callback
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8) layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(12) layout.setSpacing(12)
@@ -48,19 +58,57 @@ class ManageProfileItem(QPushButton):
layout.addLayout(texts, 1) layout.addLayout(texts, 1)
self.clicked.connect(lambda: self.selected.emit(self.profile)) 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): 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): def __init__(self, app, parent=None):
super().__init__(parent) super().__init__(parent)
self.app = app self.app = app
self.profile_manager: ProfileManager = app.profile_manager self.profile_manager: ProfileManager = app.profile_manager
self.manifest_manager = app.game_manager.manifest
self.selected_profile_id: str | None = None self.selected_profile_id: str | None = None
self.profile_items: dict[str, ManageProfileItem] = {} self.profile_items: dict[str, ManageProfileItem] = {}
self.version_options: list[AvailableVersion] = []
root = QVBoxLayout(self) root = QVBoxLayout(self)
title = QLabel("Manage Profiles (Experimental)") title = QLabel(self.app.tr_text("Manage Profiles"))
title.setObjectName("manageTitle") title.setObjectName("manageTitle")
root.addWidget(title) root.addWidget(title)
@@ -68,6 +116,7 @@ class ManageProfilePage(QWidget):
columns.setSpacing(12) columns.setSpacing(12)
root.addLayout(columns, 1) root.addLayout(columns, 1)
# Widgets
self.list_scroll = self._scroll_area("manageProfileListScroll") self.list_scroll = self._scroll_area("manageProfileListScroll")
self.list_content = QWidget() self.list_content = QWidget()
self.list_layout = QVBoxLayout(self.list_content) self.list_layout = QVBoxLayout(self.list_content)
@@ -87,7 +136,7 @@ class ManageProfilePage(QWidget):
self.options_scroll.setWidget(self.options_content) self.options_scroll.setWidget(self.options_content)
columns.addWidget(self.options_scroll, 3) 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.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setWordWrap(True) self.empty_label.setWordWrap(True)
self.options_layout.addWidget(self.empty_label, 1) self.options_layout.addWidget(self.empty_label, 1)
@@ -101,45 +150,58 @@ class ManageProfilePage(QWidget):
form.setVerticalSpacing(12) form.setVerticalSpacing(12)
self.name_edit = QLineEdit() self.name_edit = QLineEdit()
self.version_edit = QLineEdit() self.version_dropdown = QComboBox()
self.type_edit = QLineEdit() 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.game_dir_edit = QLineEdit()
self.java_args_edit = QLineEdit() self.java_args_edit = QLineEdit()
self.icon_edit = QLineEdit() self.icon_edit = QLineEdit()
self.width_spin = self._dimension_spin() self.width_spin = self._dimension_spin()
self.height_spin = self._dimension_spin() self.height_spin = self._dimension_spin()
form.addRow("Profile name", self.name_edit) form.addRow(self.app.tr_text("Profile name"), self.name_edit)
form.addRow("Version ID", self.version_edit) form.addRow(self.app.tr_text("Type"), self.type_dropdown)
form.addRow("Profile type", self.type_edit) form.addRow(self.app.tr_text("Version"), self.version_dropdown)
form.addRow("Game directory", self.game_dir_edit) form.addRow(self.app.tr_text("Game directory"), self.game_dir_edit)
form.addRow("Java arguments", self.java_args_edit) form.addRow(self.app.tr_text("Java arguments"), self.java_args_edit)
form.addRow("Icon", self.icon_edit) form.addRow(self.app.tr_text("Icon"), self.icon_edit)
resolution = QHBoxLayout() resolution = QHBoxLayout()
resolution.addWidget(self.width_spin) resolution.addWidget(self.width_spin)
resolution.addWidget(QLabel("×")) resolution.addWidget(QLabel("×"))
resolution.addWidget(self.height_spin) resolution.addWidget(self.height_spin)
form.addRow("Resolution", resolution) form.addRow(self.app.tr_text("Resolution"), resolution)
editor_layout.addLayout(form) editor_layout.addLayout(form)
file_actions = QHBoxLayout() 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) 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) import_button.clicked.connect(self.import_mods)
file_actions.addWidget(add_version_button)
file_actions.addWidget(open_button) file_actions.addWidget(open_button)
file_actions.addWidget(import_button) file_actions.addWidget(import_button)
file_actions.addStretch() file_actions.addStretch()
editor_layout.addLayout(file_actions) editor_layout.addLayout(file_actions)
actions = QHBoxLayout() actions = QHBoxLayout()
duplicate_button = QPushButton("Duplicate") duplicate_button = QPushButton(self.app.tr_text("Duplicate"))
duplicate_button.clicked.connect(self.duplicate_profile) 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.setObjectName("dangerButton")
delete_button.clicked.connect(self.delete_profile) 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.setObjectName("saveProfileButton")
save_button.clicked.connect(self.save_profile) save_button.clicked.connect(self.save_profile)
actions.addWidget(duplicate_button) actions.addWidget(duplicate_button)
@@ -152,9 +214,11 @@ class ManageProfilePage(QWidget):
self.options_layout.addStretch() self.options_layout.addStretch()
self.editor.hide() 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.app.apply_qss(Path("manage_profile.qss"), self.setStyleSheet)
self.load_profiles() self._load_type_dropdown()
self.refresh_profiles()
@staticmethod @staticmethod
def _scroll_area(name: str) -> QScrollArea: def _scroll_area(name: str) -> QScrollArea:
@@ -165,31 +229,68 @@ class ManageProfilePage(QWidget):
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return area return area
@staticmethod def _dimension_spin(self) -> QSpinBox:
def _dimension_spin() -> QSpinBox:
spin = QSpinBox() spin = QSpinBox()
spin.setRange(0, 16384) spin.setRange(0, 16384)
spin.setSpecialValueText("Default") spin.setSpecialValueText(self.app.tr_text("Default"))
spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
return spin 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: def load_profiles(self) -> None:
while self.list_layout.count(): while self.list_layout.count():
item = self.list_layout.takeAt(0) item = self.list_layout.takeAt(0)
if item.widget(): if item.widget():
item.widget().deleteLater() item.widget().deleteLater()
self.profile_items.clear() self.profile_items.clear()
profiles = self.profile_manager.list_profiles() profiles = self.profile_manager.list_profiles()
for profile in 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) item.selected.connect(self.select_profile)
self.profile_items[profile.id] = item self.profile_items[profile.id] = item
self.list_layout.addWidget(item) self.list_layout.addWidget(item)
available_ids = {profile.id for profile in profiles} available_ids = {profile.id for profile in profiles}
if self.selected_profile_id not in available_ids: if self.selected_profile_id not in available_ids:
self.selected_profile_id = None self.selected_profile_id = None
if self.selected_profile_id is None and profiles: if self.selected_profile_id is None and profiles:
current = self.profile_manager.get_current_profile_id() current = self.profile_manager.get_current_profile_id()
selected = next((p for p in profiles if p.id == current), profiles[0]) 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: def select_profile(self, profile: ProfileSummary) -> None:
detail = self.profile_manager.get_profile(profile.id) detail = self.profile_manager.get_profile(profile.id)
if detail is None: if detail is None:
return return
self.selected_profile_id = profile.id self.selected_profile_id = profile.id
for profile_id, item in self.profile_items.items(): for profile_id, item in self.profile_items.items():
item.setChecked(profile_id == profile.id) item.setChecked(profile_id == profile.id)
self._set_detail(detail) self._set_detail(detail)
self.empty_label.hide() self.empty_label.hide()
self.editor.show() 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: def _set_detail(self, profile: ProfileDetail) -> None:
self.name_edit.setText(profile.name) self.name_edit.setText(profile.name)
self.version_edit.setText(profile.version_id) option = next(
self.type_edit.setText(profile.version_type) (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.game_dir_edit.setText(str(profile.game_dir or ""))
self.java_args_edit.setText(profile.java_args) self.java_args_edit.setText(profile.java_args)
self.icon_edit.setText(profile.icon) self.icon_edit.setText(profile.icon)
@@ -226,87 +361,139 @@ class ManageProfilePage(QWidget):
def _profile_dir(self) -> Path | None: def _profile_dir(self) -> Path | None:
if self.selected_profile_id is None: if self.selected_profile_id is None:
return None return None
detail = self.profile_manager.get_profile(self.selected_profile_id) detail = self.profile_manager.get_profile(self.selected_profile_id)
if detail is None: if detail is None:
return None return None
return detail.game_dir or Path(self.app.default_game_dir) return detail.game_dir or Path(self.app.default_game_dir)
def save_profile(self) -> None: def save_profile(self) -> None:
if self.selected_profile_id is None: if self.selected_profile_id is None:
return return
width, height = self.width_spin.value(), self.height_spin.value() width, height = self.width_spin.value(), self.height_spin.value()
if (width == 0) != (height == 0): if (width == 0) != (height == 0):
self.app.widget_manager.signal.show_warning_message.emit( 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 return
game_dir_text = self.game_dir_edit.text().strip() 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( request = UpdateProfileRequest(
name=self.name_edit.text().strip(), name=self.name_edit.text().strip(),
version_id=self.version_edit.text().strip(), version_id=version.id,
version_type=self.type_edit.text().strip(), version_type=version.version_type,
game_dir=Path(game_dir_text) if game_dir_text else None, game_dir=Path(game_dir_text) if game_dir_text else None,
java_args=self.java_args_edit.text(), java_args=self.java_args_edit.text(),
icon=self.icon_edit.text().strip(), icon=self.icon_edit.text().strip(),
resolution=(width, height) if width and height else None, resolution=(width, height) if width and height else None,
) )
if self.profile_manager.validate_update(request).is_valid and self.profile_manager.update_profile( if self.profile_manager.validate_update(request).is_valid and self.profile_manager.update_profile(
self.selected_profile_id, request self.selected_profile_id, request
): ):
self.profile_manager.save() 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: else:
errors = self.profile_manager.validate_update(request).errors errors = self.profile_manager.validate_update(request).errors
if errors: if errors:
self.app.widget_manager.signal.show_warning_message.emit("\n".join(errors.values())) 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: def open_profile_folder(self) -> None:
profile_dir = self._profile_dir() profile_dir = self._profile_dir()
if profile_dir is None: if profile_dir is None:
return return
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
QDesktopServices.openUrl(QUrl.fromLocalFile(str(profile_dir.resolve()))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(profile_dir.resolve())))
def import_mods(self) -> None: def import_mods(self) -> None:
profile_dir = self._profile_dir() profile_dir = self._profile_dir()
if profile_dir is None: if profile_dir is None:
return return
files, _ = QFileDialog.getOpenFileNames( 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: if not files:
return return
mods_dir = profile_dir / "mods" mods_dir = profile_dir / "mods"
mods_dir.mkdir(parents=True, exist_ok=True) mods_dir.mkdir(parents=True, exist_ok=True)
try: try:
for source in files: for source in files:
shutil.copy2(source, mods_dir / Path(source).name) shutil.copy2(source, mods_dir / Path(source).name)
except OSError as error: 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 return
self.app.widget_manager.signal.show_info_message.emit( 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: def duplicate_profile(self, profile: ProfileSummary | None=None) -> None:
if self.selected_profile_id is None: if self.selected_profile_id is None and profile is None:
return 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: if new_id:
self.selected_profile_id = new_id self.selected_profile_id = new_id
self.load_profiles() self.load_profiles()
self.profile_manager.save() self.profile_manager.save()
def delete_profile(self) -> None: def delete_profile(self, profile: ProfileSummary | None=None) -> None:
if self.selected_profile_id is None: if self.selected_profile_id is None and profile is None:
return return
profile_id = self.selected_profile_id
answer = QMessageBox.question( 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.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No, QMessageBox.StandardButton.No,
) )
if answer == QMessageBox.StandardButton.Yes: if answer == QMessageBox.StandardButton.Yes:
profile_id = self.selected_profile_id
self.selected_profile_id = None
if self.profile_manager.remove_profile(profile_id): if self.profile_manager.remove_profile(profile_id):
self.selected_profile_id = None
self.profile_manager.save() self.profile_manager.save()
+14 -9
View File
@@ -31,24 +31,25 @@ class ProcessLogWindow(QMainWindow):
A Simple client log view 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: parent: QWidget | None = None, finished_callback: Callable[[str, int], None]=None) -> None:
super().__init__(parent) super().__init__(parent)
self.app = app
self.process = process self.process = process
self.profile_id = profile_id self.profile_id = profile_id
self._signals = _ProcessSignals(self) self._signals = _ProcessSignals(self)
self._last_error_message: str | None = None self._last_error_message: str | None = None
self._stderr_lines: deque[str] = deque(maxlen=500) self._stderr_lines: deque[str] = deque(maxlen=500)
self.setWindowTitle(title) self.setWindowTitle(self.app.tr_text(title))
self.resize(700, 720) self.resize(700, 720)
container = QWidget(self) container = QWidget(self)
layout = QVBoxLayout(container) layout = QVBoxLayout(container)
controls = QHBoxLayout() controls = QHBoxLayout()
self.status_label = QLabel(f"Running (PID {process.pid})") self.status_label = QLabel(self.app.tr_text("Running (PID {pid})", pid=process.pid))
self.kill_button = QPushButton("Kill Process") self.kill_button = QPushButton(self.app.tr_text("Kill Process"))
self.kill_button.clicked.connect(self.kill_process) self.kill_button.clicked.connect(self.kill_process)
self.log_output = QPlainTextEdit() self.log_output = QPlainTextEdit()
@@ -135,18 +136,20 @@ class ProcessLogWindow(QMainWindow):
return return
self.kill_button.setEnabled(False) self.kill_button.setEnabled(False)
self.status_label.setText("Killing process...") self.status_label.setText(self.app.tr_text("Killing process..."))
try: try:
self.process.kill() self.process.kill()
except Exception as exc: except Exception as exc:
self.kill_button.setEnabled(True) 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}") self.append_message(f"[launcher] Unable to kill process: {exc}")
def _process_finished(self, exit_code: int) -> None: def _process_finished(self, exit_code: int) -> None:
self.kill_button.setEnabled(False) 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}.") self.append_message(f"[launcher] Process exited with code {exit_code}.")
if exit_code != 0 and self._last_error_message: if exit_code != 0 and self._last_error_message:
@@ -164,8 +167,10 @@ class ProcessLogWindow(QMainWindow):
messagebox.error_with_plain_text( messagebox.error_with_plain_text(
self, self,
title="Minecraft Client Error", title=self.app.tr_text("Minecraft Client Error"),
message=f"Minecraft exited with code {exit_code}.", message=self.app.tr_text(
"Minecraft exited with code {exit_code}.", exit_code=exit_code
),
content=error_content, content=error_content,
) )
+65 -20
View File
@@ -111,9 +111,44 @@ class SettingsPage(QWidget):
root_layout.addWidget(self.stack) root_layout.addWidget(self.stack)
self.app.apply_qss(Path("settings.qss"), self.setStyleSheet) self.app.apply_qss(Path("settings.qss"), self.setStyleSheet)
self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty) 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.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: def _create_main_page(self) -> QWidget:
page = QWidget() page = QWidget()
page.setObjectName("settingsMainPage") page.setObjectName("settingsMainPage")
@@ -132,16 +167,17 @@ class SettingsPage(QWidget):
scroll_layout.setContentsMargins(24, 20, 24, 24) scroll_layout.setContentsMargins(24, 20, 24, 24)
scroll_layout.setSpacing(12) scroll_layout.setSpacing(12)
title = QLabel("Settings") tr = self.app.tr_text
title = QLabel(tr("Settings"))
title.setObjectName("settingsPageTitle") title.setObjectName("settingsPageTitle")
scroll_layout.addWidget(title) scroll_layout.addWidget(title)
scroll_layout.addSpacing(8) scroll_layout.addSpacing(8)
pages = ( pages = (
("General", "Language and launcher directories", "general_page"), (tr("General"), tr("Language and launcher directories"), "general_page"),
("Appearance", "Launcher theme", "appearance_page"), (tr("Appearance"), tr("Launcher theme"), "appearance_page"),
("Account", "Microsoft authentication settings", "account_page"), (tr("Account"), tr("Microsoft authentication settings"), "account_page"),
("About", "Version and project information", "about_page"), (tr("About"), tr("Version and project information"), "about_page"),
) )
for title_text, description, page_name in pages: for title_text, description, page_name in pages:
button = SettingsItem(title_text, description) button = SettingsItem(title_text, description)
@@ -157,21 +193,22 @@ class SettingsPage(QWidget):
return page return page
def _create_general_page(self) -> QWidget: 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) 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 = QLineEdit(self.app.work_dir.as_posix())
launcher_dir.setReadOnly(True) launcher_dir.setReadOnly(True)
page.content_layout.addWidget(launcher_dir) 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 = QLineEdit(self.app.program_dir.as_posix())
program_dir.setReadOnly(True) program_dir.setReadOnly(True)
page.content_layout.addWidget(program_dir) page.content_layout.addWidget(program_dir)
page.content_layout.addSpacing(8) page.content_layout.addSpacing(8)
page.content_layout.addWidget(QLabel("Language")) page.content_layout.addWidget(QLabel(tr("Language")))
self.language_combo = self._choice_combo( self.language_combo = self._choice_combo(
"general", "language", LANGUAGE_LABELS, current.language "general", "language", LANGUAGE_LABELS, current.language
) )
@@ -179,10 +216,11 @@ class SettingsPage(QWidget):
return page return page
def _create_appearance_page(self) -> QWidget: 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) 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( self.theme_combo = self._choice_combo(
"appearance", "theme", THEME_LABELS, current.theme "appearance", "theme", THEME_LABELS, current.theme
) )
@@ -190,30 +228,32 @@ class SettingsPage(QWidget):
return page return page
def _create_account_page(self) -> QWidget: 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) 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 = QLineEdit(str(current.client_id))
self.client_id_edit.setPlaceholderText( self.client_id_edit.setPlaceholderText(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
) )
page.content_layout.addWidget(self.client_id_edit) 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 " "The Client ID must belong to an Azure application configured "
"for Microsoft device-code authentication." "for Microsoft device-code authentication."
) ))
help_label.setObjectName("settingsHelpText") help_label.setObjectName("settingsHelpText")
help_label.setWordWrap(True) help_label.setWordWrap(True)
page.content_layout.addWidget(help_label) page.content_layout.addWidget(help_label)
return page return page
def _create_about_page(self) -> QWidget: 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 = QLabel(LAUNCHER_NAME)
brand_label.setObjectName("settingsBrand") brand_label.setObjectName("settingsBrand")
version_label = QLabel(f"Version: {LAUNCHER_VERSION}") version_label = QLabel(tr("Version: {version}", version=LAUNCHER_VERSION))
version_label.setObjectName("settingsVersion") version_label.setObjectName("settingsVersion")
description = QPlainTextEdit(LAUNCHER_DESCRIPTION) description = QPlainTextEdit(LAUNCHER_DESCRIPTION)
description.setObjectName("settingsDescription") description.setObjectName("settingsDescription")
@@ -233,7 +273,10 @@ class SettingsPage(QWidget):
) -> QComboBox: ) -> QComboBox:
combo = QComboBox() combo = QComboBox()
for value in self.settings.choices(section, field): 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) index = combo.findData(current)
combo.setCurrentIndex(max(index, 0)) combo.setCurrentIndex(max(index, 0))
return combo return combo
@@ -257,10 +300,12 @@ class SettingsPage(QWidget):
self.settings.save() self.settings.save()
except Exception as error: except Exception as error:
if show_error: if show_error:
QMessageBox.critical(self, "Settings error", str(error)) QMessageBox.critical(self, self.app.tr_text("Settings error"), str(error))
return False return False
self._settings_dirty = False self._settings_dirty = False
selected_language = data["general"]["language"]
self.app.apply_language(selected_language)
return True return True
def _open_page(self, page: QWidget) -> None: def _open_page(self, page: QWidget) -> None:
+7 -7
View File
@@ -43,31 +43,31 @@ class TestPage(QWidget):
self.resize(800, 600) self.resize(800, 600)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) 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.setObjectName("ActionButton")
self.button.clicked.connect(self.run_test) 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.setObjectName("ActionButton")
self.button_2.clicked.connect(self.run_download_client) 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.setObjectName("ActionButton")
self.button_3.clicked.connect(self.run_download_libraries) 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.setObjectName("ActionButton")
self.button_4.clicked.connect(self.load_all_version) 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.setObjectName("ActionButton")
self.button_5.clicked.connect(self.download_assets) 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.setObjectName("ActionButton")
self.button_6.clicked.connect(self.parse_argument) 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.setObjectName("ActionButton")
self.button_7.clicked.connect(self.launch_offline_game) self.button_7.clicked.connect(self.launch_offline_game)
+35
View File
@@ -82,3 +82,38 @@ QLabel#settingsVersion {
QPlainTextEdit#settingsDescription { QPlainTextEdit#settingsDescription {
font-size: 13px; font-size: 13px;
} }
QWidget#settingsPage QComboBox {
min-height: 22px;
padding: 6px 32px 6px 10px;
border: 1px solid palette(mid);
border-radius: 6px;
background-color: palette(base);
color: palette(text);
selection-background-color: palette(highlight);
selection-color: palette(highlighted-text);
}
QWidget#settingsPage QComboBox:hover {
border-color: palette(highlight);
}
QWidget#settingsPage QComboBox:focus {
border: 2px solid palette(highlight);
padding: 5px 31px 5px 9px;
}
/*QWidget#settingsPage QComboBox::drop-down {*/
/* width: 28px;*/
/* border: none;*/
/* border-left: 1px solid palette(mid);*/
/*}*/
QWidget#settingsPage QComboBox QAbstractItemView {
border: 1px solid palette(mid);
background-color: palette(base);
color: palette(text);
selection-background-color: palette(highlight);
selection-color: palette(highlighted-text);
outline: none;
}
+275
View File
@@ -0,0 +1,275 @@
{
"About": "About",
"Account": "Account",
"Account status": "Account status",
"Accounts": "Accounts",
"Add Account": "Add Account",
"Add Modded Version": "Add Modded Version",
"Add Version": "Add Version",
"Added version {version_id}.": "Added version {version_id}.",
"Added {version_id}.": "Added {version_id}.",
"All assets downloaded successfully.": "All assets downloaded successfully.",
"All libraries downloaded successfully.": "All libraries downloaded successfully.",
"Appearance": "Appearance",
"Authentication failed.": "Authentication failed.",
"Authentication failed. Session may expire.": "Authentication failed. Session may expire.",
"Available": "Available",
"Cancel": "Cancel",
"Check login status": "Check login status",
"Checking Minecraft ownership": "Checking Minecraft ownership",
"Choose a Mojang Java runtime component to install.": "Choose a Mojang Java runtime component to install.",
"Close": "Close",
"Completed": "Completed",
"Copied!": "Copied!",
"Copy code": "Copy code",
"Copy the code below, then continue in your browser.": "Copy the code below, then continue in your browser.",
"Create a local mod-loader version manifest without creating a profile.": "Create a local mod-loader version manifest without creating a profile.",
"Create Profile": "Create Profile",
"Creating the Fabric version manifest...": "Creating the Fabric version manifest...",
"Current": "Current",
"Current launcher working directory": "Current launcher working directory",
"Current program directory": "Current program directory",
"Current version: {version}": "Current version: {version}",
"Dark": "Dark",
"Default": "Default",
"Delete": "Delete",
"Delete account": "Delete account",
"Delete All": "Delete All",
"Delete Profile": "Delete Profile",
"Delete this profile? Game files will not be removed.": "Delete this profile? Game files will not be removed.",
"Detected Java runtimes": "Detected Java runtimes",
"Don't Install": "Don't Install",
"Download assets": "Download assets",
"Download client": "Download client",
"Download Error": "Download Error",
"Download libraries": "Download libraries",
"Downloaded": "Downloaded",
"Downloading": "Downloading",
"Downloading assets for version {version}": "Downloading assets for version {version}",
"Downloading libraries for version {version}": "Downloading libraries for version {version}",
"Downloading missing assets": "Downloading missing assets",
"Downloads": "Downloads",
"Duplicate": "Duplicate",
"English": "English",
"Error": "Error",
"Fabric": "Fabric",
"Failed: {error}": "Failed: {error}",
"Fetch Error": "Fetch Error",
"Fetch Version Data": "Fetch Version Data",
"Follow system settings": "Follow system settings",
"Game directory": "Game directory",
"Game Log": "Game Log",
"General": "General",
"Home": "Home",
"Icon": "Icon",
"Import Mods": "Import Mods",
"Import Mods…": "Import Mods…",
"Imported {count} mod(s).": "Imported {count} mod(s).",
"Info": "Info",
"Install": "Install",
"Install Java": "Install Java",
"Installation failed: {error}": "Installation failed: {error}",
"Installing {component}...": "Installing {component}...",
"Java {version} installed successfully.": "Java {version} installed successfully.",
"Java arguments": "Java arguments",
"Java Version:": "Java Version:",
"Kill Process": "Kill Process",
"Killing process...": "Killing process...",
"Language": "Language",
"Language and launcher directories": "Language and launcher directories",
"Launch Failed": "Launch Failed",
"Launch Game": "Launch Game",
"Launch Game! (Offline)": "Launch Game! (Offline)",
"Launch Profile": "Launch Profile",
"Launch Request": "Launch Request",
"Launched!": "Launched!",
"Launcher error occurred.": "Launcher error occurred.",
"Launcher theme": "Launcher theme",
"Light": "Light",
"Loading all types...": "Loading all types...",
"Loading all versions...": "Loading all versions...",
"Loading available Mojang runtimes...": "Loading available Mojang runtimes...",
"Loading Fabric versions for {version}...": "Loading Fabric versions for {version}...",
"Loading Minecraft versions...": "Loading Minecraft versions...",
"Login Minecraft Account": "Login Minecraft Account",
"Main repo": "Main repo",
"Manage Profiles": "Manage Profiles",
"Microsoft authentication settings": "Microsoft authentication settings",
"Microsoft Client ID": "Microsoft Client ID",
"Microsoft Login": "Microsoft Login",
"Microsoft OAuth Client ID": "Microsoft OAuth Client ID",
"Microsoft sign-in could not be completed.": "Microsoft sign-in could not be completed.",
"Microsoft sign-in failed": "Microsoft sign-in failed",
"Minecraft Client Error": "Minecraft Client Error",
"Minecraft exited with code {exit_code}.": "Minecraft exited with code {exit_code}.",
"Minecraft type": "Minecraft type",
"Minecraft version": "Minecraft version",
"Mod Loader": "Mod Loader",
"Mod Loader Version": "Mod Loader Version",
"Mod loader": "Mod loader",
"Modded": "Modded",
"Mojang runtime component": "Mojang runtime component",
"No accounts yet. Use Add Account to sign in.": "No accounts yet. Use Add Account to sign in.",
"No content": "No content",
"No Minecraft version is available for this type.": "No Minecraft version is available for this type.",
"No profiles available": "No profiles available",
"No type selected.": "No type selected.",
"No version selected.": "No version selected.",
"Open browser": "Open browser",
"Open Profile Folder": "Open Profile Folder",
"Ouch. The icon is missing.": "Ouch. The icon is missing.",
"Parse Arguments": "Parse Arguments",
"Parse Error": "Parse Error",
"Preparing...": "Preparing...",
"Preparing to launch...": "Preparing to launch...",
"Process exited with code {exit_code}": "Process exited with code {exit_code}",
"Profile name": "Profile name",
"Profile name:": "Profile name:",
"Profile name cannot be empty.": "Profile name cannot be empty.",
"Profile settings saved.": "Profile settings saved.",
"Ready to install.": "Ready to install.",
"Redownload": "Redownload",
"Refresh": "Refresh",
"Refresh all": "Refresh all",
"Refresh versions": "Refresh versions",
"Requesting a Microsoft sign-in code…": "Requesting a Microsoft sign-in code…",
"Resolution": "Resolution",
"Resource Error": "Resource Error",
"Running (PID {pid})": "Running (PID {pid})",
"Save Changes": "Save Changes",
"Select a loader version to continue.": "Select a loader version to continue.",
"Select a Minecraft and loader version.": "Select a Minecraft and loader version.",
"Select a profile from the list to edit its settings.": "Select a profile from the list to edit its settings.",
"Select a profile to launch": "Select a profile to launch",
"Select a runtime component.": "Select a runtime component.",
"Select a version.": "Select a version.",
"Select a version before continuing.": "Select a version before continuing.",
"Select a version to continue:": "Select a version to continue:",
"Set both resolution values, or set both to Default.": "Set both resolution values, or set both to Default.",
"Settings": "Settings",
"Settings error": "Settings error",
"Signing in to Minecraft services": "Signing in to Minecraft services",
"Signing in to Xbox Live": "Signing in to Xbox Live",
"Some assets could not be downloaded. They are not required to launch the game, but missing assets may cause texture issues while you play.": "Some assets could not be downloaded. They are not required to launch the game, but missing assets may cause texture issues while you play.",
"Some files failed to download.": "Some files failed to download.",
"Some files failed to download. Would you like to retry downloading them?": "Some files failed to download. Would you like to retry downloading them?",
"Still digging!": "Still digging!",
"Switch to this account": "Switch to this account",
"Test Error": "Test Error",
"Tests": "Tests",
"The Client ID must belong to an Azure application configured for Microsoft device-code authentication.": "The Client ID must belong to an Azure application configured for Microsoft device-code authentication.",
"The selected loader version is invalid.": "The selected loader version is invalid.",
"Theme": "Theme",
"There is no profile is selected. Please create one first.": "There is no profile is selected. Please create one first.",
"This version requires Java {version}.": "This version requires Java {version}.",
"Type": "Type",
"Unable to kill process": "Unable to kill process",
"Unable to launch profile": "Unable to launch profile",
"Unable to load the Minecraft version manifest.": "Unable to load the Minecraft version manifest.",
"Unable to load the selected profile.": "Unable to load the selected profile.",
"Version": "Version",
"Version and project information": "Version and project information",
"Version Type": "Version Type",
"Version: Vanilla": "Version: Vanilla",
"Version: {version}": "Version: {version}",
"Waiting for Microsoft sign-in approval": "Waiting for Microsoft sign-in approval",
"Warning": "Warning",
"If you find an issue, you can report it in the main repository. Suggestions are always welcome!": "If you find an issue, you can report it in the main repository. Suggestions are always welcome!",
"An error occurred while connecting to authentication server.": "An error occurred while connecting to authentication server.",
"An error occurred while executing Java runtime.": "An error occurred while executing Java runtime.",
"An error occurred while fetching third party API.": "An error occurred while fetching third party API.",
"An error occurred while processing account data.": "An error occurred while processing account data.",
"An error occurred while processing profile.": "An error occurred while processing profile.",
"An settings error occurred.": "An settings error occurred.",
"Current platform or architecture is not supported.": "Current platform or architecture is not supported.",
"Delete all accounts": "Delete all accounts",
"Delete every saved account? This cannot be undone.": "Delete every saved account? This cannot be undone.",
"Microsoft approved the sign-in but did not return an access token. Please sign in again.": "Microsoft approved the sign-in but did not return an access token. Please sign in again.",
"Microsoft could not refresh this account. Please sign in again.": "Microsoft could not refresh this account. Please sign in again.",
"Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.": "Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.",
"Microsoft did not return a sign-in code. Check the client ID or update the launcher.": "Microsoft did not return a sign-in code. Check the client ID or update the launcher.",
"Microsoft sign-in was rejected (HTTP {status}): {description}": "Microsoft sign-in was rejected (HTTP {status}): {description}",
"Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.": "Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.",
"Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.": "Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.",
"Minecraft services returned an invalid ownership response. Please sign in again or try later.": "Minecraft services returned an invalid ownership response. Please sign in again or try later.",
"Modded version {version_id} added.": "Modded version {version_id} added.",
"No compatible native library was found for this platform.": "No compatible native library was found for this platform.",
"No version found. (This should never happen)": "No version found. (This should never happen)",
"Runtime manifest is missing or corrupted.": "Runtime manifest is missing or corrupted.",
"Settings section could not be registered.": "Settings section could not be registered.",
"Settings section could not be validated.": "Settings section could not be validated.",
"Specified account was not found.": "Specified account was not found.",
"Specified asset data section (key) not found.": "Specified asset data section (key) not found.",
"Specified profile key not found. Profile may corrupted.": "Specified profile key not found. Profile may corrupted.",
"Specified version data section (key) not found.": "Specified version data section (key) not found.",
"Specified version or type not found.": "Specified version or type not found.",
"The authentication request or session was rejected.": "The authentication request or session was rejected.",
"The authentication service received too many requests. Wait a moment and try again.": "The authentication service received too many requests. Wait a moment and try again.",
"The authentication service returned an unexpected response. Try again later.": "The authentication service returned an unexpected response. Try again later.",
"The login session is invalid or expired. Sign in again.": "The login session is invalid or expired. Sign in again.",
"Unable to extract native libraries.": "Unable to extract native libraries.",
"Unable to fetch asset manifest.": "Unable to fetch asset manifest.",
"Unable to fetch runtime manifest.": "Unable to fetch runtime manifest.",
"Unable to fetch third party API. Provider may no longer exist. Try updating the launcher.": "Unable to fetch third party API. Provider may no longer exist. Try updating the launcher.",
"Unable to fetch third party API. Try updating the launcher.": "Unable to fetch third party API. Try updating the launcher.",
"Unable to fetch version manifest.": "Unable to fetch version manifest.",
"Unable to find dependency.": "Unable to find dependency.",
"Unable to load account data. The account file may be corrupted.": "Unable to load account data. The account file may be corrupted.",
"Unable to load asset manifest.": "Unable to load asset manifest.",
"Unable to load profile. Profile may corrupted": "Unable to load profile. Profile may corrupted",
"Unable to load version manifest.": "Unable to load version manifest.",
"Unable to parse Java runtime details.": "Unable to parse Java runtime details.",
"Unable to resolve native libraries.": "Unable to resolve native libraries.",
"Unable to save account data. Try again later.": "Unable to save account data. Try again later.",
"Unable to save asset manifest.": "Unable to save asset manifest.",
"Unable to save profile. Try again later.": "Unable to save profile. Try again later.",
"Unable to save version manifest.": "Unable to save version manifest.",
"Unable to verify Minecraft ownership because the ownership list is missing or invalid.": "Unable to verify Minecraft ownership because the ownership list is missing or invalid.",
"Unable to verify Minecraft ownership because the ownership response is invalid.": "Unable to verify Minecraft ownership because the ownership response is invalid.",
"Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.": "Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.",
"Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.": "Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.",
"Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.": "Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.",
"Chinese (Traditional)": "Chinese (Traditional)",
"Installing Java ({component})": "Installing Java ({component})",
"Unable to load component list: {error}": "Unable to load component list: {error}",
"Target profile {profile} is running. Are you sure you want to launch it again?": "Target profile {profile} is running. Are you sure you want to launch it again?",
"Time out waiting for request for launch.": "Time out waiting for request for launch.",
"Imported {count} mod(s) into {directory}.": "Imported {count} mod(s) into {directory}.",
"Unable to import mod: {error}": "Unable to import mod: {error}",
"Loader version": "Loader version",
"Unable to load versions: {error}": "Unable to load versions: {error}",
"Unable to load Fabric versions: {error}": "Unable to load Fabric versions: {error}",
"Unable to add version: {error}": "Unable to add version: {error}",
"Unable to read version types: {error}": "Unable to read version types: {error}",
"Missing icon file: {path}": "Missing icon file: {path}",
"Missing stylesheet file: {path}": "Missing stylesheet file: {path}",
"Failed to load icon {path}: {error}": "Failed to load icon {path}: {error}",
"Unable to apply QSS stylesheet {path}: {error}": "Unable to apply QSS stylesheet {path}: {error}",
"{account} — Minecraft profile {status}": "{account} — Minecraft profile {status}",
"available": "available",
"not available": "not available",
"Profile {profile_id} successfully created.": "Profile {profile_id} successfully created.",
"Profile {profile_id} with fabric loader successfully created.": "Profile {profile_id} with fabric loader successfully created.",
"Timeout while waiting for selected version.": "Timeout while waiting for selected version.",
"Selected a version before creating profile.": "Selected a version before creating profile.",
"Timeout while waiting for selected mod loader.": "Timeout while waiting for selected mod loader.",
"Loader {loader} not found.": "Loader {loader} not found.",
"Profile creation for loader {loader} is not implemented.": "Profile creation for loader {loader} is not implemented.",
"Timeout while waiting for selected mod loader version.": "Timeout while waiting for selected mod loader version.",
"Unable to create profile due to manifest fetch failure.": "Unable to create profile due to manifest fetch failure.",
"Unable to create profile due to version ID is missing in manifest.": "Unable to create profile due to version ID is missing in manifest.",
"Selected a fabric loader version before creating profile.": "Selected a fabric loader version before creating profile.",
"Unable to create profile due to vanilla manifest fetch failure.": "Unable to create profile due to vanilla manifest fetch failure.",
"Unable to create profile due to mod loader manifest fetch failure.": "Unable to create profile due to mod loader manifest fetch failure.",
"Unable to create profile. Exising loader version ID mismatch.": "Unable to create profile. Exising loader version ID mismatch.",
"Unable to create profile. Inherits version is mismatched as expected.": "Unable to create profile. Inherits version is mismatched as expected.",
"Selected a forge loader version before creating profile.": "Selected a forge loader version before creating profile.",
"Selected a neoforge loader version before creating profile.": "Selected a neoforge loader version before creating profile.",
"Timeout while waiting for selected type.": "Timeout while waiting for selected type.",
"Timeout while waiting for selected loader.": "Timeout while waiting for selected loader.",
"Selected loader \"{loader}\" is not implemented": "Selected loader \"{loader}\" is not implemented",
"Failed to fetch version manifest: {error}": "Failed to fetch version manifest: {error}",
"An unexpected error occurred while fetching version manifest: {error}": "An unexpected error occurred while fetching version manifest: {error}",
"Unable to launch profile {profile}: {error}": "Unable to launch profile {profile}: {error}",
"To make all page apply new language. Is recommend to restart the launcher after change it.": "To make all page apply new language. Is recommend to restart the launcher after change it."
}
+275
View File
@@ -0,0 +1,275 @@
{
"About": "關於",
"Account": "帳號",
"Account status": "帳號狀態",
"Accounts": "帳號",
"Add Account": "新增帳號",
"Add Modded Version": "新增模組載入器版本",
"Add Version": "新增版本",
"Added version {version_id}.": "新增版本 {version_id} 。",
"All assets downloaded successfully.": "所有資源皆已成功下載。",
"All libraries downloaded successfully.": "所有程式庫皆已成功下載。",
"Appearance": "外觀",
"Authentication failed.": "驗證失敗。",
"Authentication failed. Session may expire.": "驗證失敗,登入工作階段可能已過期。",
"Available": "可用",
"Cancel": "取消",
"Check login status": "檢查登入狀態",
"Checking Minecraft ownership": "正在檢查 Minecraft 擁有權",
"Choose a Mojang Java runtime component to install.": "選擇要安裝的 Mojang Java 執行環境元件。",
"Close": "關閉",
"Completed": "已完成",
"Copied!": "已複製!",
"Copy code": "複製代碼",
"Copy the code below, then continue in your browser.": "複製下方代碼,然後在瀏覽器中繼續。",
"Create a local mod-loader version manifest without creating a profile.": "建立本機模組載入器版本資訊,而不建立設定檔。",
"Create Profile": "建立設定檔",
"Creating the Fabric version manifest...": "正在建立 Fabric 版本資訊……",
"Current": "目前使用",
"Current launcher working directory": "目前啟動器工作目錄",
"Current program directory": "目前程式目錄",
"Current version: {version}": "目前版本:{version}",
"Dark": "深色",
"Default": "預設",
"Delete": "刪除",
"Delete account": "刪除帳號",
"Delete All": "全部刪除",
"Delete Profile": "刪除設定檔",
"Delete this profile? Game files will not be removed.": "確定要刪除此設定檔嗎?遊戲檔案不會被移除。",
"Detected Java runtimes": "偵測到的 Java 執行環境",
"Don't Install": "不要安裝",
"Download assets": "下載資源",
"Download client": "下載用戶端",
"Download Error": "下載錯誤",
"Download libraries": "下載程式庫",
"Downloaded": "已下載",
"Downloading": "正在下載",
"Downloading assets for version {version}": "正在下載版本 {version} 的資源",
"Downloading libraries for version {version}": "正在下載版本 {version} 的程式庫",
"Downloading missing assets": "正在下載缺少的資源",
"Downloads": "下載項目",
"Duplicate": "建立副本",
"English": "English",
"Error": "錯誤",
"Fabric": "Fabric",
"Failed: {error}": "失敗:{error}",
"Fetch Error": "取得資料時發生錯誤",
"Fetch Version Data": "取得版本資料",
"Follow system settings": "跟隨系統設定",
"Game directory": "遊戲目錄",
"Game Log": "遊戲記錄",
"General": "一般",
"Home": "首頁",
"Icon": "圖示",
"Import Mods": "匯入模組",
"Import Mods…": "匯入模組……",
"Imported {count} mod(s).": "已匯入 {count} 個模組。",
"Info": "資訊",
"Install": "安裝",
"Install Java": "安裝 Java",
"Installation failed: {error}": "安裝失敗:{error}",
"Installing {component}...": "正在安裝 {component}……",
"Java {version} installed successfully.": "Java {version} 已成功安裝。",
"Java arguments": "Java 引數",
"Java Version:": "Java 版本:",
"Kill Process": "終止處理程序",
"Killing process...": "正在終止處理程序……",
"Language": "語言",
"Language and launcher directories": "語言與啟動器目錄",
"Launch Failed": "啟動失敗",
"Launch Game": "啟動遊戲",
"Launch Game! (Offline)": "啟動遊戲!(離線)",
"Launch Profile": "啟動設定檔",
"Launch Request": "啟動要求",
"Launched!": "已啟動!",
"Launcher error occurred.": "啟動器發生錯誤。",
"Launcher theme": "啟動器佈景主題",
"Light": "淺色",
"Loading all types...": "正在載入所有類型……",
"Loading all versions...": "正在載入所有版本……",
"Loading available Mojang runtimes...": "正在載入可用的 Mojang 執行環境……",
"Loading Fabric versions for {version}...": "正在載入適用於 {version} 的 Fabric 版本……",
"Loading Minecraft versions...": "正在載入 Minecraft 版本……",
"Login Minecraft Account": "登入 Minecraft 帳號",
"Main repo": "主要程式碼儲存庫",
"Manage Profiles": "管理設定檔",
"Microsoft authentication settings": "Microsoft 驗證設定",
"Microsoft Client ID": "Microsoft 用戶端 ID",
"Microsoft Login": "Microsoft 登入",
"Microsoft OAuth Client ID": "Microsoft OAuth 用戶端 ID",
"Microsoft sign-in could not be completed.": "無法完成 Microsoft 登入。",
"Microsoft sign-in failed": "Microsoft 登入失敗",
"Minecraft Client Error": "Minecraft 用戶端錯誤",
"Minecraft exited with code {exit_code}.": "Minecraft 已結束,結束代碼為 {exit_code}。",
"Minecraft type": "Minecraft 類型",
"Minecraft version": "Minecraft 版本",
"Mod Loader": "模組載入器",
"Mod Loader Version": "模組載入器版本",
"Mod loader": "模組載入器",
"Modded": "模組版",
"Mojang runtime component": "Mojang 執行環境元件",
"No accounts yet. Use Add Account to sign in.": "尚未新增帳號。請使用「新增帳號」登入。",
"No content": "沒有內容",
"No Minecraft version is available for this type.": "此類型沒有可用的 Minecraft 版本。",
"No profiles available": "沒有可用的設定檔",
"No type selected.": "尚未選擇類型。",
"No version selected.": "尚未選擇版本。",
"Open browser": "開啟瀏覽器",
"Open Profile Folder": "開啟設定檔資料夾",
"Ouch. The icon is missing.": "糟糕,找不到圖示。",
"Parse Arguments": "解析引數",
"Parse Error": "解析錯誤",
"Preparing...": "正在準備……",
"Preparing to launch...": "正在準備啟動……",
"Process exited with code {exit_code}": "處理程序已結束,結束代碼為 {exit_code}",
"Profile name": "設定檔名稱",
"Profile name:": "設定檔名稱:",
"Profile name cannot be empty.": "設定檔名稱不可為空白。",
"Profile settings saved.": "設定檔設定已儲存。",
"Ready to install.": "已準備好安裝。",
"Redownload": "重新下載",
"Refresh": "重新整理",
"Refresh all": "全部重新整理",
"Refresh versions": "重新整理版本",
"Requesting a Microsoft sign-in code…": "正在要求 Microsoft 登入代碼……",
"Resolution": "解析度",
"Resource Error": "資源錯誤",
"Running (PID {pid})": "執行中(PID {pid}",
"Save Changes": "儲存變更",
"Select a loader version to continue.": "選擇載入器版本以繼續。",
"Select a Minecraft and loader version.": "請選擇 Minecraft 與載入器版本。",
"Select a profile from the list to edit its settings.": "從清單選擇設定檔以編輯其設定。",
"Select a profile to launch": "選擇要啟動的設定檔",
"Select a runtime component.": "請選擇執行環境元件。",
"Select a version.": "請選擇版本。",
"Select a version before continuing.": "請先選擇版本再繼續。",
"Select a version to continue:": "選擇版本以繼續:",
"Set both resolution values, or set both to Default.": "請同時設定寬度與高度,或將兩者都設為預設值。",
"Settings": "設定",
"Settings error": "設定錯誤",
"Signing in to Minecraft services": "正在登入 Minecraft 服務",
"Signing in to Xbox Live": "正在登入 Xbox Live",
"Some assets could not be downloaded. They are not required to launch the game, but missing assets may cause texture issues while you play.": "部分資源無法下載。這些資源不是啟動遊戲的必要項目,但缺少資源可能造成遊戲中的材質問題。",
"Some files failed to download.": "部分檔案下載失敗。",
"Some files failed to download. Would you like to retry downloading them?": "部分檔案下載失敗。要重試下載嗎?",
"Still digging!": "仍在努力開發中!",
"Switch to this account": "切換至此帳號",
"Test Error": "測試錯誤",
"Tests": "測試",
"The Client ID must belong to an Azure application configured for Microsoft device-code authentication.": "用戶端 ID 必須屬於已設定 Microsoft 裝置代碼驗證的 Azure 應用程式。",
"The selected loader version is invalid.": "選取的載入器版本無效。",
"Theme": "佈景主題",
"There is no profile is selected. Please create one first.": "尚未選擇設定檔,請先建立一個設定檔。",
"This version requires Java {version}.": "此版本需要 Java {version}。",
"Type": "類型",
"Unable to kill process": "無法終止處理程序",
"Unable to launch profile": "無法啟動設定檔",
"Unable to load the Minecraft version manifest.": "無法載入 Minecraft 版本資訊。",
"Unable to load the selected profile.": "無法載入選取的設定檔。",
"Version": "版本",
"Version and project information": "版本與專案資訊",
"Version Type": "版本類型",
"Version: Vanilla": "版本:原版",
"Version: {version}": "版本:{version}",
"Waiting for Microsoft sign-in approval": "正在等待 Microsoft 登入核准",
"Warning": "警告",
"If you find an issue, you can report it in the main repository. Suggestions are always welcome!": "如果發現問題,可以在主要程式碼儲存庫中回報。也歡迎提供任何建議!",
"An error occurred while connecting to authentication server.": "連線至驗證伺服器時發生錯誤。",
"An error occurred while executing Java runtime.": "執行 Java 執行環境時發生錯誤。",
"An error occurred while fetching third party API.": "取得第三方 API 資料時發生錯誤。",
"An error occurred while processing account data.": "處理帳號資料時發生錯誤。",
"An error occurred while processing profile.": "處理設定檔時發生錯誤。",
"An settings error occurred.": "發生設定錯誤。",
"Current platform or architecture is not supported.": "不支援目前的平台或處理器架構。",
"Delete all accounts": "刪除所有帳號",
"Delete every saved account? This cannot be undone.": "確定要刪除所有已儲存的帳號嗎?此操作無法復原。",
"Microsoft approved the sign-in but did not return an access token. Please sign in again.": "Microsoft 已核准登入,但未傳回存取權杖。請重新登入。",
"Microsoft could not refresh this account. Please sign in again.": "Microsoft 無法重新整理此帳號。請重新登入。",
"Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.": "Microsoft 未傳回重新整理權杖。請確認已啟用 offline_access 權限,然後重新登入。",
"Microsoft did not return a sign-in code. Check the client ID or update the launcher.": "Microsoft 未傳回登入代碼。請檢查用戶端 ID 或更新啟動器。",
"Microsoft sign-in was rejected (HTTP {status}): {description}": "Microsoft 登入遭拒絕(HTTP {status}):{description}",
"Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.": "Minecraft 服務已接受 Xbox 工作階段,但未傳回遊戲存取權杖。請重新登入。",
"Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.": "Minecraft 服務未傳回有效的玩家資料。請確認此帳號擁有 Minecraft 且已建立玩家資料。",
"Minecraft services returned an invalid ownership response. Please sign in again or try later.": "Minecraft 服務傳回無效的擁有權回應。請重新登入或稍後再試。",
"Modded version {version_id} added.": "已新增模組版本 {version_id}。",
"No compatible native library was found for this platform.": "找不到適用於此平台的原生程式庫。",
"No version found. (This should never happen)": "找不到任何版本。(正常情況下不應發生)",
"Runtime manifest is missing or corrupted.": "執行環境資訊遺失或損毀。",
"Settings section could not be registered.": "無法註冊設定區段。",
"Settings section could not be validated.": "無法驗證設定區段。",
"Specified account was not found.": "找不到指定的帳號。",
"Specified asset data section (key) not found.": "找不到指定的資源資料區段(鍵)。",
"Specified profile key not found. Profile may corrupted.": "找不到指定的設定檔鍵,設定檔可能已損毀。",
"Specified version data section (key) not found.": "找不到指定的版本資料區段(鍵)。",
"Specified version or type not found.": "找不到指定的版本或類型。",
"The authentication request or session was rejected.": "驗證要求或工作階段遭到拒絕。",
"The authentication service received too many requests. Wait a moment and try again.": "驗證服務收到過多要求。請稍候再試。",
"The authentication service returned an unexpected response. Try again later.": "驗證服務傳回非預期的回應。請稍後再試。",
"The login session is invalid or expired. Sign in again.": "登入工作階段無效或已過期。請重新登入。",
"Unable to extract native libraries.": "無法解壓縮原生程式庫。",
"Unable to fetch asset manifest.": "無法取得資源資訊。",
"Unable to fetch runtime manifest.": "無法取得執行環境資訊。",
"Unable to fetch third party API. Provider may no longer exist. Try updating the launcher.": "無法取得第三方 API 資料。服務提供者可能已不存在,請嘗試更新啟動器。",
"Unable to fetch third party API. Try updating the launcher.": "無法取得第三方 API 資料。請嘗試更新啟動器。",
"Unable to fetch version manifest.": "無法取得版本資訊。",
"Unable to find dependency.": "找不到相依項目。",
"Unable to load account data. The account file may be corrupted.": "無法載入帳號資料,帳號檔案可能已損毀。",
"Unable to load asset manifest.": "無法載入資源資訊。",
"Unable to load profile. Profile may corrupted": "無法載入設定檔,設定檔可能已損毀。",
"Unable to load version manifest.": "無法載入版本資訊。",
"Unable to parse Java runtime details.": "無法解析 Java 執行環境詳細資料。",
"Unable to resolve native libraries.": "無法解析原生程式庫。",
"Unable to save account data. Try again later.": "無法儲存帳號資料,請稍後再試。",
"Unable to save asset manifest.": "無法儲存資源資訊。",
"Unable to save profile. Try again later.": "無法儲存設定檔,請稍後再試。",
"Unable to save version manifest.": "無法儲存版本資訊。",
"Unable to verify Minecraft ownership because the ownership list is missing or invalid.": "無法驗證 Minecraft 擁有權,因為擁有權清單遺失或無效。",
"Unable to verify Minecraft ownership because the ownership response is invalid.": "無法驗證 Minecraft 擁有權,因為擁有權回應無效。",
"Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.": "Xbox Live 已接受登入,但未傳回驗證權杖。請重新登入。",
"Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.": "Xbox Live 未傳回帳號識別碼。請檢查帳號的 Xbox 個人檔案後再試。",
"Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.": "Xbox 安全性授權未傳回權杖。此帳號可能需要建立 Xbox 個人檔案,或可能受到限制。",
"Added {version_id}.": "已新增 {version_id}。",
"Chinese (Traditional)": "繁體中文",
"Installing Java ({component})": "正在安裝 Java{component}",
"Unable to load component list: {error}": "無法載入元件清單:{error}",
"Target profile {profile} is running. Are you sure you want to launch it again?": "設定檔 {profile} 正在執行。確定要再次啟動嗎?",
"Time out waiting for request for launch.": "等待啟動確認逾時。",
"Imported {count} mod(s) into {directory}.": "已將 {count} 個模組匯入 {directory}。",
"Unable to import mod: {error}": "無法匯入模組:{error}",
"Loader version": "載入器版本",
"Unable to load versions: {error}": "無法載入版本:{error}",
"Unable to load Fabric versions: {error}": "無法載入 Fabric 版本:{error}",
"Unable to add version: {error}": "無法新增版本:{error}",
"Unable to read version types: {error}": "無法讀取版本類型:{error}",
"Missing icon file: {path}": "找不到圖示檔案:{path}",
"Missing stylesheet file: {path}": "找不到樣式表檔案:{path}",
"Failed to load icon {path}: {error}": "無法載入圖示 {path}{error}",
"Unable to apply QSS stylesheet {path}: {error}": "無法套用 QSS 樣式表 {path}{error}",
"{account} — Minecraft profile {status}": "{account} — Minecraft 玩家資料{status}",
"available": "可用",
"not available": "不可用",
"Profile {profile_id} successfully created.": "設定檔 {profile_id} 已成功建立。",
"Profile {profile_id} with fabric loader successfully created.": "已成功建立含 Fabric 載入器的設定檔 {profile_id}。",
"Timeout while waiting for selected version.": "等待選取版本逾時。",
"Selected a version before creating profile.": "請先選擇版本再建立設定檔。",
"Timeout while waiting for selected mod loader.": "等待選取模組載入器逾時。",
"Loader {loader} not found.": "找不到載入器 {loader}。",
"Profile creation for loader {loader} is not implemented.": "尚未實作載入器 {loader} 的設定檔建立功能。",
"Timeout while waiting for selected mod loader version.": "等待選取模組載入器版本逾時。",
"Unable to create profile due to manifest fetch failure.": "因無法取得版本資訊,所以無法建立設定檔。",
"Unable to create profile due to version ID is missing in manifest.": "版本資訊缺少版本 ID,無法建立設定檔。",
"Selected a fabric loader version before creating profile.": "請先選擇 Fabric 載入器版本再建立設定檔。",
"Unable to create profile due to vanilla manifest fetch failure.": "因無法取得原版版本資訊,所以無法建立設定檔。",
"Unable to create profile due to mod loader manifest fetch failure.": "因無法取得模組載入器版本資訊,所以無法建立設定檔。",
"Unable to create profile. Exising loader version ID mismatch.": "載入器版本 ID 不一致,無法建立設定檔。",
"Unable to create profile. Inherits version is mismatched as expected.": "繼承的版本不符合預期,無法建立設定檔。",
"Selected a forge loader version before creating profile.": "請先選擇 Forge 載入器版本再建立設定檔。",
"Selected a neoforge loader version before creating profile.": "請先選擇 NeoForge 載入器版本再建立設定檔。",
"Timeout while waiting for selected type.": "等待選取類型逾時。",
"Timeout while waiting for selected loader.": "等待選取載入器逾時。",
"Selected loader \"{loader}\" is not implemented": "尚未實作選取的載入器「{loader}」",
"Failed to fetch version manifest: {error}": "無法取得版本資訊:{error}",
"An unexpected error occurred while fetching version manifest: {error}": "取得版本資訊時發生非預期錯誤:{error}",
"Unable to launch profile {profile}: {error}": "無法啟動設定檔 {profile}{error}",
"To make all page apply new language. Is recommend to restart the launcher after change it.": "為了讓所有頁面套用語言更新,建議在修改後重新啟動啟動器。"
}
+76 -27
View File
@@ -116,13 +116,17 @@ class LauncherMainWindow(QMainWindow):
"Suggestions are always welcome!") "Suggestions are always welcome!")
self.icon_label = QLabel() self.icon_label = QLabel()
self.icon_label.setObjectName("icon_label") self.icon_label.setObjectName("icon_label")
self.dev_info_box = QLabel(content) self.dev_info_box = QLabel(self.app.tr_text(content))
self.dev_info_box.setObjectName("dev_info_box") self.dev_info_box.setObjectName("dev_info_box")
self.dev_info_2 = QLabel(message) self.dev_info_2 = QLabel(self.app.tr_text(message))
self.dev_info_2.setObjectName("dev_info_message") self.dev_info_2.setObjectName("dev_info_message")
self.version_label = QLabel("Current version: {}".format(self.app.launcher_version)) self.version_label = QLabel(self.app.tr_text(
"Current version: {version}", version=self.app.launcher_version
))
self.version_label.setObjectName("version_label") self.version_label.setObjectName("version_label")
self.repo_url_label = QLabel(f"<a href=\"{MAIN_REPO_URL}\">Main repo</a>") self.repo_url_label = QLabel(
f'<a href="{MAIN_REPO_URL}">{self.app.tr_text("Main repo")}</a>'
)
self.repo_url_label.setObjectName("repo_url_label") self.repo_url_label.setObjectName("repo_url_label")
self.repo_url_label.setOpenExternalLinks(True) self.repo_url_label.setOpenExternalLinks(True)
@@ -130,7 +134,7 @@ class LauncherMainWindow(QMainWindow):
image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png")) image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png"))
self.icon_label.setPixmap(image.scaled(300, 300)) self.icon_label.setPixmap(image.scaled(300, 300))
else: else:
self.icon_label.setText("Ouch. The icon is missing.") self.icon_label.setText(self.app.tr_text("Ouch. The icon is missing."))
# Dialog # Dialog
self.download_dialog = ProgressDialog(self) self.download_dialog = ProgressDialog(self)
@@ -146,12 +150,13 @@ class LauncherMainWindow(QMainWindow):
self.home_layout.addWidget(self.repo_url_label, alignment=Qt.AlignmentFlag.AlignHCenter) self.home_layout.addWidget(self.repo_url_label, alignment=Qt.AlignmentFlag.AlignHCenter)
self.home_layout.addStretch() self.home_layout.addStretch()
# test page if self.app.debug:
self.test_page = TestPage( # test page
app=self.app, self.test_page = TestPage(
parent=self, app=self.app,
widget_manager=self.app.widget_manager parent=self,
) widget_manager=self.app.widget_manager
)
self.launch_profile_page = LaunchProfilePage( self.launch_profile_page = LaunchProfilePage(
app=self.app, app=self.app,
@@ -172,7 +177,8 @@ class LauncherMainWindow(QMainWindow):
# Add page # Add page
self.central.addWidget(self.home_page) self.central.addWidget(self.home_page)
self.central.addWidget(self.test_page) if self.app.debug:
self.central.addWidget(self.test_page)
self.central.addWidget(self.account_page) self.central.addWidget(self.account_page)
self.central.addWidget(self.settings_page) self.central.addWidget(self.settings_page)
self.central.addWidget(self.create_profile_page) self.central.addWidget(self.create_profile_page)
@@ -190,18 +196,31 @@ class LauncherMainWindow(QMainWindow):
# Toolbar # Toolbar
# Test Window btn # Test Window btn
self.open_test_page = self.toolbar.add_button(" Tests", icon=self.app.get_icon(Path(self.app.icon_dir, "debug.png"))) if self.app.debug:
self.open_test_page.setObjectName("open_test_page") self.open_test_page = self.toolbar.add_button(" Tests")
self.app.set_reloadable_toolbutton_icon(
self.open_test_page,
Path(self.app.icon_dir, "debug.png"),
)
self.open_test_page.setObjectName("open_test_page")
# Launch profile btn # Launch profile btn
self.open_launch_profile_page = self.toolbar.add_button(" Launch Profile", self.open_launch_profile_page = self.toolbar.add_button(" Launch Profile",
icon=self.app.get_icon(Path(self.app.icon_dir, "profile_default.png"), True)) icon=self.app.get_icon(Path(self.app.icon_dir, "profile_default.png"), True))
# Home btn # Home btn
self.home_button = self.toolbar.add_button(" Home", icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) self.home_button = self.toolbar.add_button(" Home")
self.app.set_reloadable_toolbutton_icon(
self.home_button,
Path(self.app.icon_dir, "home.png"),
)
# Settings btn (will be moved to the bottom of the toolbar) # Settings btn (will be moved to the bottom of the toolbar)
self.open_settings_page = self.toolbar.add_button(" Settings", icon=self.app.get_icon(Path(self.app.icon_dir, "settings.png"))) self.open_settings_page = self.toolbar.add_button(" Settings")
self.app.set_reloadable_toolbutton_icon(
self.open_settings_page,
Path(self.app.icon_dir, "settings.png"),
)
# Account btn (will be moved to the bottom (or last one item) of the toolbar # Account btn (will be moved to the bottom (or last one item) of the toolbar
self.open_account_page = self.toolbar.add_button(" Accounts", icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"), True)) self.open_account_page = self.toolbar.add_button(" Accounts", icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"), True))
@@ -209,8 +228,11 @@ class LauncherMainWindow(QMainWindow):
# current account's avatar # current account's avatar
# Profile btn # Profile btn
self.open_create_profile_page = self.toolbar.add_button("Create Profile", self.open_create_profile_page = self.toolbar.add_button("Create Profile")
icon=self.app.get_icon(Path(self.app.icon_dir, "add.png"))) self.app.set_reloadable_toolbutton_icon(
self.open_create_profile_page,
Path(self.app.icon_dir, "add.png"),
)
# manage profile # manage profile
self.open_manage_profile_page = self.toolbar.add_button( self.open_manage_profile_page = self.toolbar.add_button(
@@ -220,7 +242,8 @@ class LauncherMainWindow(QMainWindow):
# Bind tool buttons # Bind tool buttons
self.toolbar.addWidget(self.home_button) self.toolbar.addWidget(self.home_button)
self.toolbar.addWidget(self.open_test_page) if self.app.debug:
self.toolbar.addWidget(self.open_test_page)
self.toolbar.addWidget(self.open_create_profile_page) self.toolbar.addWidget(self.open_create_profile_page)
self.toolbar.addWidget(self.open_launch_profile_page) self.toolbar.addWidget(self.open_launch_profile_page)
self.toolbar.addWidget(self.open_manage_profile_page) self.toolbar.addWidget(self.open_manage_profile_page)
@@ -238,9 +261,10 @@ class LauncherMainWindow(QMainWindow):
lambda: self.set_current_page(self.home_page, self.home_button) lambda: self.set_current_page(self.home_page, self.home_button)
) )
self.open_test_page.clicked.connect( if self.app.debug:
lambda: self.set_current_page(self.test_page, self.open_test_page) self.open_test_page.clicked.connect(
) lambda: self.set_current_page(self.test_page, self.open_test_page)
)
self.open_launch_profile_page.clicked.connect( self.open_launch_profile_page.clicked.connect(
lambda: self.set_current_page(self.launch_profile_page, self.open_launch_profile_page) lambda: self.set_current_page(self.launch_profile_page, self.open_launch_profile_page)
@@ -266,6 +290,31 @@ class LauncherMainWindow(QMainWindow):
self.bind_event() self.bind_event()
self.set_current_page(self.home_page, self.home_button) self.set_current_page(self.home_page, self.home_button)
self.app.i18n.language_changed.connect(self.retranslate_ui)
self.retranslate_ui()
def retranslate_ui(self, *_args):
tr = self.app.tr_text
self.dev_info_box.setText(tr("Still digging!"))
self.dev_info_2.setText(tr(
"If you find an issue, you can report it in the main repository. "
"Suggestions are always welcome!"
))
self.version_label.setText(
tr("Current version: {version}", version=self.app.launcher_version)
)
self.repo_url_label.setText(
f'<a href="{MAIN_REPO_URL}">{tr("Main repo")}</a>'
)
self.home_button.setText(" " + tr("Home"))
self.open_launch_profile_page.setText(" " + tr("Launch Profile"))
self.open_settings_page.setText(" " + tr("Settings"))
self.open_account_page.setText(" " + tr("Accounts"))
self.open_create_profile_page.setText(" " + tr("Create Profile"))
self.open_manage_profile_page.setText(" " + tr("Manage Profiles"))
if self.app.debug:
self.open_test_page.setText(" " + tr("Tests"))
self.toolbar.update_all()
@Slot(object) @Slot(object)
def ask_request(self, context: AskForRequest): def ask_request(self, context: AskForRequest):
@@ -309,8 +358,8 @@ class LauncherMainWindow(QMainWindow):
@Slot(dict) @Slot(dict)
def show_and_print_error(self, context: dict): def show_and_print_error(self, context: dict):
error = context.get("error", None) error = context.get("error", None)
title = context.get("title", "Error") title = context.get("title", self.app.tr_text("Error"))
message = context.get("message", "No content") message = context.get("message", self.app.tr_text("No content"))
if isinstance(error, list): if isinstance(error, list):
self.app.logger.error("".join(error)) self.app.logger.error("".join(error))
@@ -335,15 +384,15 @@ class LauncherMainWindow(QMainWindow):
@Slot(str) @Slot(str)
def show_error(self, message): def show_error(self, message):
messagebox.error(self, "Error", message=message) messagebox.error(self, self.app.tr_text("Error"), message=message)
@Slot(str) @Slot(str)
def show_warning(self, widget): def show_warning(self, widget):
messagebox.warning(self, "Warning", message=widget) messagebox.warning(self, self.app.tr_text("Warning"), message=widget)
@Slot(str) @Slot(str)
def show_info(self, widget): def show_info(self, widget):
messagebox.info(self, "Info", message=widget) messagebox.info(self, self.app.tr_text("Info"), message=widget)
def set_current_page(self, page: QWidget, related_button: QToolButton): def set_current_page(self, page: QWidget, related_button: QToolButton):
self.central.setCurrentWidget(page) self.central.setCurrentWidget(page)