diff --git a/app.py b/app.py index 182d5cd..1cd37b6 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,17 @@ import sys +from _colorize import Theme from pathlib import Path from typing import Callable from PySide6.QtCore import QSize -from PySide6.QtGui import QIcon, QPixmap -from PySide6.QtWidgets import QApplication, QStyle +from PySide6.QtGui import QIcon, QPixmap, QPalette, QColor +from PySide6.QtWidgets import QApplication, QStyle, QToolButton from constant import LAUNCHER_VERSION from core_lib.qt.hack import move_window_to_center, is_light_theme, \ recolor_icon from core_lib.qt import messagebox +from core_lib.i18n import TranslationManager from core_lib.launcher.object import Launcher as LauncherObject from manager.account import AccountManager from manager.game import GameManager @@ -22,6 +24,8 @@ from window import LauncherMainWindow class Launcher(QApplication, LauncherObject): def __init__(self, logger, debug: bool = False): super().__init__() + self._system_palette = QPalette(self.palette()) # theme + # logger self.logger = logger self.debug = debug @@ -41,6 +45,15 @@ class Launcher(QApplication, LauncherObject): self.settings_manager.load() else: 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.account_manager = AccountManager(self, self.widget_manager) self.game_manager = GameManager(self) @@ -73,8 +86,8 @@ class Launcher(QApplication, LauncherObject): if not path.exists() or not path.is_file(): messagebox.error( self.main_window, - "Resource Error", - "Missing icon file: {}".format(path) + self.tr_text("Resource Error"), + self.tr_text("Missing icon file: {path}", path=path) ) return icon @@ -89,19 +102,58 @@ class Launcher(QApplication, LauncherObject): except Exception as e: messagebox.error( self.main_window, - "Resource Error", - "Failed to load icon {}: {}".format(path, e) + self.tr_text("Resource Error"), + self.tr_text("Failed to load icon {path}: {error}", path=path, error=e) ) 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: picture = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning).pixmap(size) if not path.exists() or not path.is_file(): messagebox.error( self.main_window, - "Resource Error", - "Missing icon file: {}".format(path) if not custom_error else custom_error + self.tr_text("Resource Error"), + self.tr_text("Missing icon file: {path}", path=path) + if not custom_error else custom_error ) return picture @@ -111,8 +163,8 @@ class Launcher(QApplication, LauncherObject): except Exception as e: messagebox.error( self.main_window, - "Resource Error", - "Failed to load icon {}: {}".format(path, e) + self.tr_text("Resource Error"), + self.tr_text("Failed to load icon {path}: {error}", path=path, error=e) ) return picture @@ -167,8 +219,8 @@ class Launcher(QApplication, LauncherObject): if not full_path.exists(): messagebox.error( None, - "Resource Error", - "Missing stylesheet file: {}".format(full_path), + self.tr_text("Resource Error"), + self.tr_text("Missing stylesheet file: {path}", path=full_path), ) return "" @@ -178,11 +230,86 @@ class Launcher(QApplication, LauncherObject): except Exception as e: messagebox.error( self.main_window, - "Resource Error", - "Unable to apply QSS stylesheet {}: {}".format(full_path, e), + self.tr_text("Resource Error"), + self.tr_text( + "Unable to apply QSS stylesheet {path}: {error}", + path=full_path, + error=e, + ), ) return "" def get_main_window(self): 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 diff --git a/manager/account.py b/manager/account.py index a9ffc24..1397bb1 100644 --- a/manager/account.py +++ b/manager/account.py @@ -739,8 +739,8 @@ class AccountManager(QObject): # Ask user before actually delete answer = QMessageBox.question( self.app.main_window, - "Delete all accounts", - "Delete every saved account? This cannot be undone.", + self.app.tr_text("Delete all accounts"), + self.app.tr_text("Delete every saved account? This cannot be undone."), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) diff --git a/manager/game.py b/manager/game.py index 998f720..9e465b0 100644 --- a/manager/game.py +++ b/manager/game.py @@ -1,5 +1,6 @@ import json import os +import re import subprocess import sys import threading @@ -15,7 +16,7 @@ from PySide6.QtCore import QObject, Signal from constant import CLIENT_ID, LAUNCHER_VERSION 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 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 @@ -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, \ save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \ 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.profile import LaunchProfile from manager.widgets import SelectPathRequest, AskForRequest, SignalTransferItem 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: def __init__(self, app): self.app = app @@ -305,10 +315,112 @@ class ManifestManager: 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: def __init__(self, app): self.app = app self.widget_mgr = app.widget_manager + self.runtimes: list[JavaRuntime] = self._read_runtimes() # # JVM Management @@ -317,81 +429,210 @@ class JavaManager: def jvm_settings_path(self) -> Path: return Path(self.app.work_dir) / "jvms.json" - def _find_and_save_jvm_search_result(self): - installations, errors = find_java_installations() - if not installations: - self.widget_mgr.signal.show_warning_message.emit( - "There are no Java installations available in you system.\n" - ) + @staticmethod + def _runtime_from_dict(data: dict) -> JavaRuntime: + """ + Convert runtime data to JavaRuntime object + :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 [] - if errors: - request = AskForRequest("Java Virtual Machine Finder", message = ( - "Unable to detect below java installation version:\n" - )) - request.details = "\n".join(path.as_posix() for path in errors) - request.use_plain_text = True - - self.widget_mgr.signal.askyesno.emit(request) - + def save_runtimes(self) -> None: + """ + Save memory runtimes to jvms.json + :return: + """ self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True) - try: - with self.jvm_settings_path.open("w") as file: - json.dump(installations, file, indent=4) + temporary = self.jvm_settings_path.with_suffix(".json.tmp") + temporary.write_text( + json.dumps([runtime.to_dict() for runtime in self.runtimes], indent=4), + encoding="utf-8", + ) - return installations - 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.", - } - ) + temporary.replace(self.jvm_settings_path) - def _read_exist_jvm_settings(self): - try: - return json.loads(self.jvm_settings_path.read_text()) - 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 reading jvm settings.", - } - ) + @staticmethod + def inspect_runtime(executable: str | Path, + *, + source: Literal["system", "managed", "custom"] = "custom", + component: str | None = None, + runtime_id: str | None = None, + ) -> JavaRuntime: + """ + 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 + 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: - if self.jvm_settings_path.exists(): - exists = self._read_exist_jvm_settings() + def list_installable_runtimes(self) -> list[InstallableJavaRuntime]: + """Return Mojang components with the Java version carried by each one.""" + 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: - return exists + def list_installable_components(self) -> list[str]: + """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: - result = self.get_or_cache_jvm_settings() - - if not result: - 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 + runtime = self.resolve_runtime(major_version=int(major_version)) + if runtime: + return runtime.executable.as_posix() if not no_error: self.widget_mgr.signal.show_error_message.emit( @@ -400,6 +641,81 @@ class JavaManager: 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): def __init__(self, job_name: Literal["download_client", "download_libraries", "download_assets"], args: tuple): @@ -650,6 +966,7 @@ class LaunchResult: process: subprocess.Popen | None = None error_message: str | None = None warnings: list[str] = field(default_factory=list) + fail_callback: Callable | None = None @property def success(self) -> bool: @@ -657,6 +974,8 @@ class LaunchResult: def fail(self, message: str) -> "LaunchResult": self.error_message = message + if callable(self.fail_callback): + self.fail_callback() return self def add_warning(self, message: str) -> None: @@ -680,6 +999,11 @@ class LaunchManager: def launch(self, profile: LaunchProfile) -> LaunchResult: result = LaunchResult() + + def fail_callback(): + self.profile_failed(profile.profile_id) + + result.fail_callback = fail_callback try: version_id = profile.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." ) - # Use found java runtime (if available) - java_path = self.java_mgr.get_support_runtime_jvm_executable( - java_major_version, - no_error=True - ) + # Resolve a compatible runtime, then offer the Mojang runtime from + # the version manifest before falling back to a custom executable. + runtime = self.java_mgr.resolve_runtime(manifest=version_manifest) + 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: # Ask for a support java version. If not, use system environment java @@ -840,10 +1190,19 @@ class LaunchManager: java_path = request.result() 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}") else: - self.app.logger.debug("Use system environment java.") - java_path = "java" + return result.fail(f"Java {java_major_version} is required to launch this version.") self.app.logger.debug(f"Java executable path is: {java_path}") @@ -913,12 +1272,32 @@ class LaunchManager: 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: 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 with code {exit_code}." ) diff --git a/pages/account.py b/pages/account.py index b38469e..fbd5593 100644 --- a/pages/account.py +++ b/pages/account.py @@ -25,9 +25,10 @@ from manager.settings_models import AccountSettings class AccountWidget(QWidget): - def __init__(self, account_name: str, account_head: QPixmap, login_status: str, parent=None, + def __init__(self, app, account_name: str, account_head: QPixmap, login_status: str, parent=None, /, delete_account_callback=None, check_account_callback=None, switch_account_callback=None,): super().__init__(parent) + self.app = app # Layout self.layout = QHBoxLayout(self) @@ -57,10 +58,10 @@ class AccountWidget(QWidget): def show_right_click_menu(self, position): menu = QMenu(self) - switch_action = menu.addAction("Switch to this account") - check_action = menu.addAction("Check login status") + switch_action = menu.addAction(self.app.tr_text("Switch to this account")) + check_action = menu.addAction(self.app.tr_text("Check login status")) menu.addSeparator() - delete_action = menu.addAction("Delete account") + delete_action = menu.addAction(self.app.tr_text("Delete account")) selected = menu.exec(self.mapToGlobal(position)) if selected == delete_action and callable(self.delete_account_callback): @@ -74,7 +75,7 @@ class LoginDialog(QDialog): def __init__(self, app, parent=None, code="", error_message=""): super().__init__(parent) self.app = app - self.setWindowTitle("Microsoft Login") + self.setWindowTitle(self.app.tr_text("Microsoft Login")) self.setMinimumWidth(440) self.setMinimumHeight(380) self.resize(440, 420) @@ -86,9 +87,9 @@ class LoginDialog(QDialog): self.layout.setSpacing(14) # Top widgets - self.title_label = QLabel("Login Minecraft Account") + self.title_label = QLabel(self.app.tr_text("Login Minecraft Account")) self.title_label.setObjectName("dialogTitle") - self.help_label = QLabel("Copy the code below, then continue in your browser.") + self.help_label = QLabel(self.app.tr_text("Copy the code below, then continue in your browser.")) self.help_label.setObjectName("helpText") self.help_label.setWordWrap(True) @@ -106,9 +107,9 @@ class LoginDialog(QDialog): self.error_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) # Button - self.copy_code_button = QPushButton("Copy code") + self.copy_code_button = QPushButton(self.app.tr_text("Copy code")) self.copy_code_button.setObjectName("copyCodeButton") - self.open_browser_button = QPushButton("Open browser") + self.open_browser_button = QPushButton(self.app.tr_text("Open browser")) self.open_browser_button.setObjectName("openBrowserButton") self.center_layout = QVBoxLayout() @@ -142,12 +143,12 @@ class LoginDialog(QDialog): def copy_code(self): QApplication.clipboard().setText(self.code) - self.copy_code_button.setText("Copied!") + self.copy_code_button.setText(self.app.tr_text("Copied!")) self.copy_code_button.setEnabled(False) QTimer.singleShot(1200, self._reset_copy_button) def _reset_copy_button(self): - self.copy_code_button.setText("Copy code") + self.copy_code_button.setText(self.app.tr_text("Copy code")) self.copy_code_button.setEnabled(True) def set_error_message(self, message): @@ -173,9 +174,9 @@ class AccountPage(QWidget): # Widgets self.account_list = QListWidget(self) - self.add_account_button = QPushButton("Add Account") - self.refresh_button = QPushButton("Refresh all") - self.delete_all_button = QPushButton("Delete All") + self.add_account_button = QPushButton(self.app.tr_text("Add Account")) + self.refresh_button = QPushButton(self.app.tr_text("Refresh all")) + self.delete_all_button = QPushButton(self.app.tr_text("Delete All")) # Button click handle self.add_account_button.clicked.connect(self.start_login_page) @@ -244,7 +245,7 @@ class AccountPage(QWidget): # Add suggestion to account list if no account available if not accounts: - empty_item = QListWidgetItem("No accounts yet. Use Add Account to sign in.") + empty_item = QListWidgetItem(self.app.tr_text("No accounts yet. Use Add Account to sign in.")) empty_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) empty_item.setFlags(Qt.ItemFlag.NoItemFlags) self.account_list.addItem(empty_item) @@ -258,9 +259,10 @@ class AccountPage(QWidget): head_size = QSize(32, 32) widget = AccountWidget( + self.app, account.display_name, self.account_manager.get_head_pixmap(account.avatar, head_size), - "Current" if is_current else "Available", + self.app.tr_text("Current" if is_current else "Available"), self, delete_account_callback=lambda: self._delete_account(account.id), check_account_callback=lambda: self._show_account_status(account.id), @@ -310,9 +312,14 @@ class AccountPage(QWidget): QMessageBox.information( self, - "Account status", - f"{account.display_name} — Minecraft profile " - f"{'available' if account.minecraft_uuid else 'not available'}", + self.app.tr_text("Account status"), + self.app.tr_text( + "{account} — Minecraft profile {status}", + account=account.display_name, + status=self.app.tr_text( + "available" if account.minecraft_uuid else "not available" + ), + ), ) def _switch_account(self, account_id: str): @@ -336,7 +343,7 @@ class AccountPage(QWidget): # Create login dialog self.add_account_button.setEnabled(False) self.login_dialog = LoginDialog(parent=self, app=self.app) - self.login_dialog.help_label.setText("Requesting a Microsoft sign-in code…") + self.login_dialog.help_label.setText(self.app.tr_text("Requesting a Microsoft sign-in code…")) self.login_dialog.copy_code_button.setEnabled(False) self.login_dialog.open_browser_button.setEnabled(False) self.login_dialog.open_browser_button.clicked.connect(self.open_sign_in_page) @@ -351,7 +358,7 @@ class AccountPage(QWidget): if self.login_dialog is not None: self.login_dialog.help_label.setText( - device.get("message") or "Copy the code below, then continue in your browser." + device.get("message") or self.app.tr_text("Copy the code below, then continue in your browser.") ) self.login_dialog.set_code(device.get("user_code", "")) self.login_dialog.copy_code_button.setEnabled(True) @@ -378,11 +385,11 @@ class AccountPage(QWidget): def _login_failed(self, message: str): if self.login_dialog is not None: self.login_dialog.set_error_message(message) - self.login_dialog.help_label.setText("Microsoft sign-in could not be completed.") + self.login_dialog.help_label.setText(self.app.tr_text("Microsoft sign-in could not be completed.")) self.login_dialog.copy_code_button.setEnabled(False) self.login_dialog.open_browser_button.setEnabled(False) else: - QMessageBox.critical(self, "Microsoft sign-in failed", message) + QMessageBox.critical(self, self.app.tr_text("Microsoft sign-in failed"), message) def _login_finished(self): self.add_account_button.setEnabled(True) diff --git a/pages/add_modded_version.py b/pages/add_modded_version.py new file mode 100644 index 0000000..567d3d1 --- /dev/null +++ b/pages/add_modded_version.py @@ -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() diff --git a/pages/create_profile.py b/pages/create_profile.py index 156aac3..32942a1 100644 --- a/pages/create_profile.py +++ b/pages/create_profile.py @@ -13,6 +13,7 @@ from core_lib.game.version_info import get_all_versions, get_available_version_t from manager.game import ManifestManager, GameManager from manager.profile import CreateProfileRequest from manager.widgets import SignalTransferItem, WidgetManager +from pages.install_java_dialog import InstallJavaDialog class CreateProfilePage(QWidget): def __init__(self, app, parent=None): @@ -33,7 +34,7 @@ class CreateProfilePage(QWidget): self.operate_layout = QHBoxLayout() # Some items - self.version_type_label = QLabel("Version Type") + self.version_type_label = QLabel(self.app.tr_text("Version Type")) self.version_type_dropdown = QComboBox() self.version_type_dropdown.setObjectName("Dropdown") @@ -42,7 +43,7 @@ class CreateProfilePage(QWidget): icon = self.app.get_icon(Path(self.app.icon_dir, "mod_loader_temp.png")) pixmap = icon.pixmap(QSize(100, 100)) self.mod_loader_icon_label.setPixmap(pixmap) - self.mod_loader_label = QLabel("Mod Loader") + self.mod_loader_label = QLabel(self.app.tr_text("Mod Loader")) self.mod_loader_dropdown = QComboBox() self.mod_loader_dropdown.setObjectName("Dropdown") @@ -50,9 +51,7 @@ class CreateProfilePage(QWidget): self.mod_config_layout = QHBoxLayout() self.mod_config_fields_layout = QVBoxLayout() - self.mod_loader_version_label = QLabel( - "Mod Loader Version" - ) + self.mod_loader_version_label = QLabel(self.app.tr_text("Mod Loader Version")) self.mod_loader_version_list = QListWidget() self.mod_loader_version_list.setObjectName( @@ -60,23 +59,27 @@ class CreateProfilePage(QWidget): ) # Version list - self.version_list_type_label = QLabel("Version: Vanilla") - self.version_list_label = QLabel("Select a version to continue:") + self.version_list_type_label = QLabel(self.app.tr_text("Version: Vanilla")) + self.version_list_label = QLabel(self.app.tr_text("Select a version to continue:")) self.version_list = QListWidget() self.version_list.setObjectName("ListWidget") # Button - self.create_profile_button = QPushButton("Create Profile") + self.create_profile_button = QPushButton(self.app.tr_text("Create Profile")) self.create_profile_button.setObjectName("Button") - self.refresh_button = QPushButton("Refresh") + self.refresh_button = QPushButton(self.app.tr_text("Refresh")) self.refresh_button.setObjectName("Button") + self.install_java_button = QPushButton(self.app.tr_text("Install Java")) + self.install_java_button.setObjectName("Button") + # Bind widget self.main_layout.addWidget(self.version_list_label) self.main_layout.addWidget(self.version_list) self.operate_layout.addWidget(self.version_list_type_label) self.operate_layout.addStretch() + self.operate_layout.addWidget(self.install_java_button) self.operate_layout.addWidget(self.refresh_button) self.operate_layout.addWidget(self.create_profile_button) @@ -142,6 +145,7 @@ class CreateProfilePage(QWidget): # Button event self.create_profile_button.clicked.connect(self.create_profile) + self.install_java_button.clicked.connect(self.create_install_jvm_dialog) # Update version list while current type is changed self.version_type_dropdown.currentTextChanged.connect( @@ -406,7 +410,7 @@ class CreateProfilePage(QWidget): if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected version.", + self.app.tr_text("Timeout while waiting for selected version."), ) return @@ -414,7 +418,7 @@ class CreateProfilePage(QWidget): if not minecraft_version: self.widget_mgr.signal.show_warning_message.emit( - "Selected a version before creating profile.", + self.app.tr_text("Selected a version before creating profile."), ) return @@ -425,7 +429,7 @@ class CreateProfilePage(QWidget): if not loader_request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected mod loader.", + self.app.tr_text("Timeout while waiting for selected mod loader."), ) return @@ -433,7 +437,7 @@ class CreateProfilePage(QWidget): if loader_name not in self.loader_list: self.widget_mgr.signal.show_error_message.emit( - "Loader {} not found.".format(loader_name), + self.app.tr_text("Loader {loader} not found.", loader=loader_name), ) return @@ -442,7 +446,10 @@ class CreateProfilePage(QWidget): if profile_create_handler is None: self.widget_mgr.signal.show_warning_message.emit( - "Profile creation for loader {} is not implemented.".format(loader_name), + self.app.tr_text( + "Profile creation for loader {loader} is not implemented.", + loader=loader_name, + ), ) return @@ -453,7 +460,7 @@ class CreateProfilePage(QWidget): if not loader_version_request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected mod loader version.", + self.app.tr_text("Timeout while waiting for selected mod loader version."), ) return @@ -464,8 +471,8 @@ class CreateProfilePage(QWidget): profile_name, accepted = QInputDialog.getText( self, - "Create Profile", - "Profile name:", + self.app.tr_text("Create Profile"), + self.app.tr_text("Profile name:"), text=f"Minecraft {minecraft_version}", ) @@ -475,7 +482,7 @@ class CreateProfilePage(QWidget): profile_name = profile_name.strip() if not profile_name: self.widget_mgr.signal.show_warning_message.emit( - "Profile name cannot be empty.", + self.app.tr_text("Profile name cannot be empty."), ) return @@ -496,7 +503,7 @@ class CreateProfilePage(QWidget): if not manifest: self.widget_mgr.signal.show_warning_message.emit( - "Unable to create profile due to manifest fetch failure.", + self.app.tr_text("Unable to create profile due to manifest fetch failure."), ) return @@ -508,7 +515,7 @@ class CreateProfilePage(QWidget): if version_id is None: self.widget_mgr.signal.show_error_message.emit( - "Unable to create profile due to version ID is missing in manifest.", + self.app.tr_text("Unable to create profile due to version ID is missing in manifest."), ) return @@ -531,7 +538,7 @@ class CreateProfilePage(QWidget): return self.widget_mgr.signal.show_info_message.emit( - f"Profile {profile_id} successfully created.", + self.app.tr_text("Profile {profile_id} successfully created.", profile_id=profile_id), ) def handle_fabric_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None, switch_to_current=False): @@ -545,7 +552,7 @@ class CreateProfilePage(QWidget): """ if not loader_version: self.widget_mgr.signal.show_warning_message.emit( - "Selected a fabric loader version before creating profile.", + self.app.tr_text("Selected a fabric loader version before creating profile."), ) return @@ -555,7 +562,7 @@ class CreateProfilePage(QWidget): ) if not vanilla_manifest: self.widget_mgr.signal.show_error_message.emit( - "Unable to create profile due to vanilla manifest fetch failure.", + self.app.tr_text("Unable to create profile due to vanilla manifest fetch failure."), ) return @@ -573,7 +580,7 @@ class CreateProfilePage(QWidget): if not loader_manifest: self.widget_mgr.signal.show_error_message.emit( - "Unable to create profile due to mod loader manifest fetch failure.", + self.app.tr_text("Unable to create profile due to mod loader manifest fetch failure."), ) return @@ -585,7 +592,7 @@ class CreateProfilePage(QWidget): if loader_info["id"] != version_id: self.app.logger.error(f"Fabric manifest ID mismatch: expected {version_id}, got {loader_info["id"]}.") self.widget_mgr.signal.show_error_message.emit( - "Unable to create profile. Exising loader version ID mismatch.", + self.app.tr_text("Unable to create profile. Exising loader version ID mismatch."), ) return @@ -595,7 +602,7 @@ class CreateProfilePage(QWidget): f'{loader_info["inheritsFrom"]}, not {minecraft_version}.' ) self.widget_mgr.signal.show_error_message.emit( - "Unable to create profile. Inherits version is mismatched as expected.", + self.app.tr_text("Unable to create profile. Inherits version is mismatched as expected."), ) return @@ -620,20 +627,23 @@ class CreateProfilePage(QWidget): return self.widget_mgr.signal.show_info_message.emit( - f"Profile {profile_id} with fabric loader successfully created.", + self.app.tr_text( + "Profile {profile_id} with fabric loader successfully created.", + profile_id=profile_id, + ), ) def handle_forge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None): if not loader_version: self.widget_mgr.signal.show_warning_message.emit( - "Selected a forge loader version before creating profile.", + self.app.tr_text("Selected a forge loader version before creating profile."), ) def handle_neoforge_profile_create(self, profile_name: str, minecraft_version: str, loader_version: str | None): if not loader_version: self.widget_mgr.signal.show_warning_message.emit( - "Selected a neoforge loader version before creating profile.", + self.app.tr_text("Selected a neoforge loader version before creating profile."), ) # @@ -666,7 +676,7 @@ class CreateProfilePage(QWidget): if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected type.", + self.app.tr_text("Timeout while waiting for selected type."), ) return @@ -674,7 +684,7 @@ class CreateProfilePage(QWidget): if not selected_type: self.widget_mgr.signal.show_warning_message.emit( - "No type selected.", + self.app.tr_text("No type selected."), ) return @@ -715,14 +725,14 @@ class CreateProfilePage(QWidget): if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected version.", + self.app.tr_text("Timeout while waiting for selected version."), ) selected_version = request.result() if not selected_version: self.widget_mgr.signal.show_warning_message.emit( - "No version selected.", + self.app.tr_text("No version selected."), ) try: @@ -731,12 +741,17 @@ class CreateProfilePage(QWidget): ) except (VersionManifestFetchException, VersionManifestException) as e: self.widget_mgr.signal.show_error_message.emit( - "Failed to fetch version manifest: {}".format(e.user_message), + self.app.tr_text( + "Failed to fetch version manifest: {error}", error=e.user_message + ), ) return except Exception as e: self.widget_mgr.signal.show_error_message.emit( - "An unexpected error occurred while fetching version manifest: {}".format(e), + self.app.tr_text( + "An unexpected error occurred while fetching version manifest: {error}", + error=e, + ), ) return @@ -768,7 +783,7 @@ class CreateProfilePage(QWidget): if not request.wait(10): self.widget_mgr.signal.show_error_message.emit( - "Timeout while waiting for selected loader.", + self.app.tr_text("Timeout while waiting for selected loader."), ) return @@ -782,7 +797,10 @@ class CreateProfilePage(QWidget): if not version_handler: self.widget_mgr.signal.show_warning_message.emit( - "Selected loader \"{}\" is not implemented".format(loader_name), + self.app.tr_text( + 'Selected loader "{loader}" is not implemented', + loader=loader_name, + ), ) return @@ -791,4 +809,22 @@ class CreateProfilePage(QWidget): else: self.app.logger.warning( "Select mod loader \"loadVersionMethod\" is not callable." - ) \ No newline at end of file + ) + + 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() diff --git a/pages/install_java_dialog.py b/pages/install_java_dialog.py new file mode 100644 index 0000000..c2f1f99 --- /dev/null +++ b/pages/install_java_dialog.py @@ -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) diff --git a/pages/launch_profile.py b/pages/launch_profile.py index d2c4a78..227be08 100644 --- a/pages/launch_profile.py +++ b/pages/launch_profile.py @@ -208,7 +208,7 @@ class LaunchProfilePage(QWidget): self.bottom_layout = QHBoxLayout() # Widgets - self.status_label = QLabel("Select a profile to launch") + self.status_label = QLabel(self.app.tr_text("Select a profile to launch")) self.status_label.setStyleSheet("font-size: 12px; font-weight: 600;") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -221,7 +221,7 @@ class LaunchProfilePage(QWidget): self.profile_popup.profile_selected.connect(self.select_profile) self.profile_popup.closed.connect(self.refresh_profile_button) - launch_button = QPushButton("Launch Game") + launch_button = QPushButton(self.app.tr_text("Launch Game")) launch_button.setObjectName("launchButton") launch_button.setMinimumWidth(120) launch_button.setMinimumHeight(42) @@ -258,7 +258,7 @@ class LaunchProfilePage(QWidget): # self.profile_frame.setGraphicsEffect(shadow) # Header - self.header_label = QLabel("Launch Profile") + self.header_label = QLabel(self.app.tr_text("Launch Profile")) self.header_label.setObjectName("headerLabel") self.header_layout = QHBoxLayout(self.profile_overlay) @@ -322,16 +322,20 @@ class LaunchProfilePage(QWidget): :return: """ if not result.success: - self.status_label.setText("Launch Failed") + self.status_label.setText(self.app.tr_text("Launch Failed")) self.widget_mgr.signal.show_error_message.emit( - f"Unable to launch profile " - f"{launch_profile.display_name}: " - f"{result.error_message}" + self.app.tr_text( + "Unable to launch profile {profile}: {error}", + profile=launch_profile.display_name, + error=result.error_message, + ) ) + self.status_label_cleanup() return # Create log view log_window = ProcessLogWindow( + self.app, result.process, launch_profile.profile_id, title=( @@ -350,12 +354,17 @@ class LaunchProfilePage(QWidget): self.process_log_windows.append(log_window) log_window.show() - self.status_label.setText("Launched!") + self.status_label.setText(self.app.tr_text("Launched!")) # Cleanup for status label + self.status_label_cleanup() + + def status_label_cleanup(self): timer = QTimer(self) timer.setSingleShot(True) - timer.timeout.connect(lambda: self.status_label.setText("Select a profile to launch")) + timer.timeout.connect(lambda: self.status_label.setText( + self.app.tr_text("Select a profile to launch") + )) timer.start(3000) def load_profiles(self): @@ -364,7 +373,7 @@ class LaunchProfilePage(QWidget): if not profiles: self.profile_button.setProperty("profile_id", None) - self.profile_button.name_label.setText("No profiles available") + self.profile_button.name_label.setText(self.app.tr_text("No profiles available")) self.profile_button.version_label.clear() return @@ -441,30 +450,30 @@ class LaunchProfilePage(QWidget): if profile_id is None: self.widget_mgr.signal.show_warning_message.emit( - "There is no profile is selected." - " Please create one first." + self.app.tr_text("There is no profile is selected. Please create one first.") ) return - self.status_label.setText("Preparing to launch...") + self.status_label.setText(self.app.tr_text("Preparing to launch...")) launch_profile = self.app.profile_manager.get_launch_profile(profile_id) if launch_profile is None: - self.status_label.setText("Unable to load the selected profile.") + self.status_label.setText(self.app.tr_text("Unable to load the selected profile.")) return if self.launch_manager.is_profile_running(launch_profile.profile_id): request = AskForRequest( - "Launch Request", - "Target profile {} is running. Are you sure you want to launch it again?".format( - launch_profile.display_name + self.app.tr_text("Launch Request"), + self.app.tr_text( + "Target profile {profile} is running. Are you sure you want to launch it again?", + profile=launch_profile.display_name, ), ) self.widget_mgr.signal.askyesno.emit(request) if not request.wait(60): self.widget_mgr.signal.show_error_message.emit( - "Time out waiting for request for launch." + self.app.tr_text("Time out waiting for request for launch.") ) return diff --git a/pages/manage_profile.py b/pages/manage_profile.py index 29c4d4b..1f0b87f 100644 --- a/pages/manage_profile.py +++ b/pages/manage_profile.py @@ -6,23 +6,33 @@ from PySide6.QtGui import QDesktopServices, QIcon from PySide6.QtWidgets import ( QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, - QVBoxLayout, QWidget, + QVBoxLayout, QWidget, QMenu, QComboBox, ) from manager.profile import ProfileDetail, ProfileManager, ProfileSummary, UpdateProfileRequest +from manager.game import AvailableVersion +from pages.add_modded_version import AddModdedVersionDialog class ManageProfileItem(QPushButton): selected = Signal(object) - def __init__(self, app, profile: ProfileSummary, parent=None): + def __init__(self, app, profile: ProfileSummary, + parent=None, + delete_callback=None, + duplicate_callback=None): super().__init__(parent) + self.app = app self.profile = profile self.setObjectName("manageProfileItem") self.setCheckable(True) self.setCursor(Qt.CursorShape.PointingHandCursor) self.setMinimumHeight(64) + # Callback + self.delete_callback = delete_callback + self.duplicate_callback = duplicate_callback + layout = QHBoxLayout(self) layout.setContentsMargins(12, 8, 12, 8) layout.setSpacing(12) @@ -48,19 +58,57 @@ class ManageProfileItem(QPushButton): layout.addLayout(texts, 1) self.clicked.connect(lambda: self.selected.emit(self.profile)) + # Enable right click menu + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_right_click_menu) + + def show_right_click_menu(self, position): + menu = QMenu(self) + + duplicate_action = menu.addAction(self.app.tr_text("Duplicate")) + remove_action = menu.addAction(self.app.tr_text("Delete")) + + menu.addAction(duplicate_action) + menu.addAction(remove_action) + + def duplicate_callback(): + self.duplicate_callback(self.profile) + + def delete_callback(): + self.delete_callback(self.profile) + + if callable(self.duplicate_callback): + duplicate_action.triggered.connect(duplicate_callback) + + if callable(self.delete_callback): + remove_action.triggered.connect(delete_callback) + + global_position = self.mapToGlobal(position) + menu.exec(global_position) + class ManageProfilePage(QWidget): - """Select a profile on the left and edit its launcher settings on the right.""" + """ + Profile manage page + """ + + VERSION_CATEGORIES = ( + ("Vanilla", "vanilla"), + ("Modded", "modded"), + ("Custom", "custom"), + ) def __init__(self, app, parent=None): super().__init__(parent) self.app = app self.profile_manager: ProfileManager = app.profile_manager + self.manifest_manager = app.game_manager.manifest self.selected_profile_id: str | None = None self.profile_items: dict[str, ManageProfileItem] = {} + self.version_options: list[AvailableVersion] = [] root = QVBoxLayout(self) - title = QLabel("Manage Profiles (Experimental)") + title = QLabel(self.app.tr_text("Manage Profiles")) title.setObjectName("manageTitle") root.addWidget(title) @@ -68,6 +116,7 @@ class ManageProfilePage(QWidget): columns.setSpacing(12) root.addLayout(columns, 1) + # Widgets self.list_scroll = self._scroll_area("manageProfileListScroll") self.list_content = QWidget() self.list_layout = QVBoxLayout(self.list_content) @@ -87,7 +136,7 @@ class ManageProfilePage(QWidget): self.options_scroll.setWidget(self.options_content) columns.addWidget(self.options_scroll, 3) - self.empty_label = QLabel("Select a profile from the list to edit its settings.") + self.empty_label = QLabel(self.app.tr_text("Select a profile from the list to edit its settings.")) self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty_label.setWordWrap(True) self.options_layout.addWidget(self.empty_label, 1) @@ -101,45 +150,58 @@ class ManageProfilePage(QWidget): form.setVerticalSpacing(12) self.name_edit = QLineEdit() - self.version_edit = QLineEdit() - self.type_edit = QLineEdit() + self.version_dropdown = QComboBox() + self.version_dropdown.setObjectName("Dropdown") + self.version_dropdown.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + self.version_dropdown.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + self.version_dropdown.setMinimumContentsLength(24) + self.type_dropdown = QComboBox() + self.type_dropdown.setObjectName("Dropdown") self.game_dir_edit = QLineEdit() self.java_args_edit = QLineEdit() self.icon_edit = QLineEdit() self.width_spin = self._dimension_spin() self.height_spin = self._dimension_spin() - form.addRow("Profile name", self.name_edit) - form.addRow("Version ID", self.version_edit) - form.addRow("Profile type", self.type_edit) - form.addRow("Game directory", self.game_dir_edit) - form.addRow("Java arguments", self.java_args_edit) - form.addRow("Icon", self.icon_edit) + form.addRow(self.app.tr_text("Profile name"), self.name_edit) + form.addRow(self.app.tr_text("Type"), self.type_dropdown) + form.addRow(self.app.tr_text("Version"), self.version_dropdown) + form.addRow(self.app.tr_text("Game directory"), self.game_dir_edit) + form.addRow(self.app.tr_text("Java arguments"), self.java_args_edit) + form.addRow(self.app.tr_text("Icon"), self.icon_edit) resolution = QHBoxLayout() resolution.addWidget(self.width_spin) resolution.addWidget(QLabel("×")) resolution.addWidget(self.height_spin) - form.addRow("Resolution", resolution) + form.addRow(self.app.tr_text("Resolution"), resolution) editor_layout.addLayout(form) file_actions = QHBoxLayout() - open_button = QPushButton("Open Profile Folder") + add_version_button = QPushButton(self.app.tr_text("Add Modded Version")) + add_version_button.clicked.connect(self.open_add_modded_version_dialog) + open_button = QPushButton(self.app.tr_text("Open Profile Folder")) open_button.clicked.connect(self.open_profile_folder) - import_button = QPushButton("Import Mods…") + import_button = QPushButton(self.app.tr_text("Import Mods…")) import_button.clicked.connect(self.import_mods) + file_actions.addWidget(add_version_button) file_actions.addWidget(open_button) file_actions.addWidget(import_button) file_actions.addStretch() editor_layout.addLayout(file_actions) actions = QHBoxLayout() - duplicate_button = QPushButton("Duplicate") + duplicate_button = QPushButton(self.app.tr_text("Duplicate")) duplicate_button.clicked.connect(self.duplicate_profile) - delete_button = QPushButton("Delete") + delete_button = QPushButton(self.app.tr_text("Delete")) delete_button.setObjectName("dangerButton") delete_button.clicked.connect(self.delete_profile) - save_button = QPushButton("Save Changes") + save_button = QPushButton(self.app.tr_text("Save Changes")) save_button.setObjectName("saveProfileButton") save_button.clicked.connect(self.save_profile) actions.addWidget(duplicate_button) @@ -152,9 +214,11 @@ class ManageProfilePage(QWidget): self.options_layout.addStretch() self.editor.hide() - self.profile_manager.profiles_changed.connect(self.load_profiles) + self.type_dropdown.currentIndexChanged.connect(self._filter_versions) + self.profile_manager.profiles_changed.connect(self.refresh_profiles) self.app.apply_qss(Path("manage_profile.qss"), self.setStyleSheet) - self.load_profiles() + self._load_type_dropdown() + self.refresh_profiles() @staticmethod def _scroll_area(name: str) -> QScrollArea: @@ -165,31 +229,68 @@ class ManageProfilePage(QWidget): area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) return area - @staticmethod - def _dimension_spin() -> QSpinBox: + def _dimension_spin(self) -> QSpinBox: spin = QSpinBox() spin.setRange(0, 16384) - spin.setSpecialValueText("Default") + spin.setSpecialValueText(self.app.tr_text("Default")) spin.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) return spin + def _load_type_dropdown(self) -> None: + self.type_dropdown.blockSignals(True) + self.type_dropdown.clear() + for display_name, category in self.VERSION_CATEGORIES: + self.type_dropdown.addItem(self.app.tr_text(display_name), category) + self.type_dropdown.blockSignals(False) + + def refresh_profiles(self) -> None: + self.version_options = self.manifest_manager.list_available_profile_versions() + self.load_profiles() + + def _filter_versions(self, _index: int = -1) -> None: + selected_category = self.type_dropdown.currentData() + selected_option = self.version_dropdown.currentData() + selected_id = ( + selected_option.id + if isinstance(selected_option, AvailableVersion) + else None + ) + + self.version_dropdown.blockSignals(True) + self.version_dropdown.clear() + + for option in self.version_options: + if option.source == selected_category: + self.version_dropdown.addItem(option.display_name, option) + + if selected_id is not None: + self._select_version(selected_id) + + self.version_dropdown.blockSignals(False) + def load_profiles(self) -> None: while self.list_layout.count(): item = self.list_layout.takeAt(0) if item.widget(): item.widget().deleteLater() + self.profile_items.clear() profiles = self.profile_manager.list_profiles() for profile in profiles: - item = ManageProfileItem(self.app, profile, self.list_content) + item = ManageProfileItem(self.app, profile, + self.list_content, + duplicate_callback=self.duplicate_profile, + delete_callback=self.delete_profile) item.selected.connect(self.select_profile) self.profile_items[profile.id] = item self.list_layout.addWidget(item) available_ids = {profile.id for profile in profiles} + if self.selected_profile_id not in available_ids: self.selected_profile_id = None + if self.selected_profile_id is None and profiles: current = self.profile_manager.get_current_profile_id() selected = next((p for p in profiles if p.id == current), profiles[0]) @@ -203,19 +304,53 @@ class ManageProfilePage(QWidget): def select_profile(self, profile: ProfileSummary) -> None: detail = self.profile_manager.get_profile(profile.id) + if detail is None: return + self.selected_profile_id = profile.id + for profile_id, item in self.profile_items.items(): item.setChecked(profile_id == profile.id) + self._set_detail(detail) self.empty_label.hide() self.editor.show() + def _select_version(self, version_id: str) -> None: + for index in range(self.version_dropdown.count()): + data = self.version_dropdown.itemData(index) + + if isinstance(data, AvailableVersion) and data.id == version_id: + self.version_dropdown.setCurrentIndex(index) + return + + def _select_type(self, category: str) -> None: + index = self.type_dropdown.findData(category) + if index >= 0: + self.type_dropdown.setCurrentIndex(index) + def _set_detail(self, profile: ProfileDetail) -> None: self.name_edit.setText(profile.name) - self.version_edit.setText(profile.version_id) - self.type_edit.setText(profile.version_type) + option = next( + (item for item in self.version_options if item.id == profile.version_id), + None, + ) + + # Preserve profiles imported from another launcher, even when their + # version manifest is not available locally anymore. + if option is None: + option = AvailableVersion( + id=profile.version_id, + display_name=f"{profile.version_id} (Unavailable)", + version_type=profile.version_type or "custom", + source="custom", + ) + self.version_options.append(option) + + self._select_type(option.source) + self._filter_versions() + self._select_version(profile.version_id) self.game_dir_edit.setText(str(profile.game_dir or "")) self.java_args_edit.setText(profile.java_args) self.icon_edit.setText(profile.icon) @@ -226,87 +361,139 @@ class ManageProfilePage(QWidget): def _profile_dir(self) -> Path | None: if self.selected_profile_id is None: return None + detail = self.profile_manager.get_profile(self.selected_profile_id) + if detail is None: return None + return detail.game_dir or Path(self.app.default_game_dir) def save_profile(self) -> None: if self.selected_profile_id is None: return + width, height = self.width_spin.value(), self.height_spin.value() + if (width == 0) != (height == 0): self.app.widget_manager.signal.show_warning_message.emit( - "Set both resolution values, or set both to Default." + self.app.tr_text("Set both resolution values, or set both to Default.") ) return + game_dir_text = self.game_dir_edit.text().strip() + version = self.version_dropdown.currentData() + + if not isinstance(version, AvailableVersion): + self.app.widget_manager.signal.show_warning_message.emit( + self.app.tr_text("Select a version.") + ) + return + request = UpdateProfileRequest( name=self.name_edit.text().strip(), - version_id=self.version_edit.text().strip(), - version_type=self.type_edit.text().strip(), + version_id=version.id, + version_type=version.version_type, game_dir=Path(game_dir_text) if game_dir_text else None, java_args=self.java_args_edit.text(), icon=self.icon_edit.text().strip(), resolution=(width, height) if width and height else None, ) + if self.profile_manager.validate_update(request).is_valid and self.profile_manager.update_profile( self.selected_profile_id, request ): self.profile_manager.save() - self.app.widget_manager.signal.show_info_message.emit("Profile settings saved.") + self.app.widget_manager.signal.show_info_message.emit( + self.app.tr_text("Profile settings saved.") + ) else: errors = self.profile_manager.validate_update(request).errors if errors: self.app.widget_manager.signal.show_warning_message.emit("\n".join(errors.values())) + def open_add_modded_version_dialog(self) -> None: + dialog = AddModdedVersionDialog(self.app, self) + dialog.version_created.connect(self._modded_version_created) + dialog.exec() + + def _modded_version_created(self, version_id: str) -> None: + self.version_options = self.manifest_manager.list_available_profile_versions() + # self._select_type("modded") + # self._filter_versions() + # self._select_version(version_id) + self.app.widget_manager.signal.show_info_message.emit( + self.app.tr_text("Modded version {version_id} added.", version_id=version_id) + ) + def open_profile_folder(self) -> None: profile_dir = self._profile_dir() + if profile_dir is None: return + profile_dir.mkdir(parents=True, exist_ok=True) QDesktopServices.openUrl(QUrl.fromLocalFile(str(profile_dir.resolve()))) def import_mods(self) -> None: profile_dir = self._profile_dir() + if profile_dir is None: return + files, _ = QFileDialog.getOpenFileNames( - self, "Import Mods", "", "Minecraft mods (*.jar *.zip);;All files (*)" + self, self.app.tr_text("Import Mods"), "", "Minecraft mods (*.jar *.zip);;All files (*)" ) + if not files: return + mods_dir = profile_dir / "mods" mods_dir.mkdir(parents=True, exist_ok=True) + try: for source in files: shutil.copy2(source, mods_dir / Path(source).name) except OSError as error: - self.app.widget_manager.signal.show_error_message.emit(f"Unable to import mod: {error}") + self.app.widget_manager.signal.show_error_message.emit( + self.app.tr_text("Unable to import mod: {error}", error=error) + ) return + self.app.widget_manager.signal.show_info_message.emit( - f"Imported {len(files)} mod(s) into {mods_dir}." + self.app.tr_text( + "Imported {count} mod(s) into {directory}.", + count=len(files), directory=mods_dir, + ) ) - def duplicate_profile(self) -> None: - if self.selected_profile_id is None: + def duplicate_profile(self, profile: ProfileSummary | None=None) -> None: + if self.selected_profile_id is None and profile is None: return - new_id = self.profile_manager.duplicate_profile(self.selected_profile_id) + + profile_id = self.selected_profile_id + + new_id = self.profile_manager.duplicate_profile(profile_id) + if new_id: self.selected_profile_id = new_id self.load_profiles() self.profile_manager.save() - def delete_profile(self) -> None: - if self.selected_profile_id is None: + def delete_profile(self, profile: ProfileSummary | None=None) -> None: + if self.selected_profile_id is None and profile is None: return + + profile_id = self.selected_profile_id + answer = QMessageBox.question( - self, "Delete Profile", "Delete this profile? Game files will not be removed.", + self, + self.app.tr_text("Delete Profile"), + self.app.tr_text("Delete this profile? Game files will not be removed."), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if answer == QMessageBox.StandardButton.Yes: - profile_id = self.selected_profile_id - self.selected_profile_id = None if self.profile_manager.remove_profile(profile_id): + self.selected_profile_id = None self.profile_manager.save() diff --git a/pages/process_log.py b/pages/process_log.py index e6dd595..8f3856b 100644 --- a/pages/process_log.py +++ b/pages/process_log.py @@ -31,24 +31,25 @@ class ProcessLogWindow(QMainWindow): A Simple client log view """ - def __init__(self, process: subprocess.Popen, profile_id: str, title: str = "Game Log", + def __init__(self, app, process: subprocess.Popen, profile_id: str, title: str = "Game Log", parent: QWidget | None = None, finished_callback: Callable[[str, int], None]=None) -> None: super().__init__(parent) + self.app = app self.process = process self.profile_id = profile_id self._signals = _ProcessSignals(self) self._last_error_message: str | None = None self._stderr_lines: deque[str] = deque(maxlen=500) - self.setWindowTitle(title) + self.setWindowTitle(self.app.tr_text(title)) self.resize(700, 720) container = QWidget(self) layout = QVBoxLayout(container) controls = QHBoxLayout() - self.status_label = QLabel(f"Running (PID {process.pid})") - self.kill_button = QPushButton("Kill Process") + self.status_label = QLabel(self.app.tr_text("Running (PID {pid})", pid=process.pid)) + self.kill_button = QPushButton(self.app.tr_text("Kill Process")) self.kill_button.clicked.connect(self.kill_process) self.log_output = QPlainTextEdit() @@ -135,18 +136,20 @@ class ProcessLogWindow(QMainWindow): return self.kill_button.setEnabled(False) - self.status_label.setText("Killing process...") + self.status_label.setText(self.app.tr_text("Killing process...")) try: self.process.kill() except Exception as exc: self.kill_button.setEnabled(True) - self.status_label.setText("Unable to kill process") + self.status_label.setText(self.app.tr_text("Unable to kill process")) self.append_message(f"[launcher] Unable to kill process: {exc}") def _process_finished(self, exit_code: int) -> None: self.kill_button.setEnabled(False) - self.status_label.setText(f"Process exited with code {exit_code}") + self.status_label.setText(self.app.tr_text( + "Process exited with code {exit_code}", exit_code=exit_code + )) self.append_message(f"[launcher] Process exited with code {exit_code}.") if exit_code != 0 and self._last_error_message: @@ -164,8 +167,10 @@ class ProcessLogWindow(QMainWindow): messagebox.error_with_plain_text( self, - title="Minecraft Client Error", - message=f"Minecraft exited with code {exit_code}.", + title=self.app.tr_text("Minecraft Client Error"), + message=self.app.tr_text( + "Minecraft exited with code {exit_code}.", exit_code=exit_code + ), content=error_content, ) diff --git a/pages/settings.py b/pages/settings.py index 5bb6b30..cba9700 100644 --- a/pages/settings.py +++ b/pages/settings.py @@ -111,9 +111,44 @@ class SettingsPage(QWidget): root_layout.addWidget(self.stack) self.app.apply_qss(Path("settings.qss"), self.setStyleSheet) self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty) - self.theme_combo.currentIndexChanged.connect(self._mark_settings_dirty) self.client_id_edit.textEdited.connect(self._mark_settings_dirty) + self.theme_combo.currentIndexChanged.connect( + self._apply_selected_theme + ) + self.app.i18n.language_changed.connect(self.retranslate_ui) + + def _apply_selected_theme(self) -> None: + theme = self.theme_combo.currentData() + self.app.apply_theme(theme) + self._mark_settings_dirty() + + def retranslate_ui(self, *_args) -> None: + """Rebuild this page so every label uses the active catalog.""" + current_page = self.stack.currentIndex() + while self.stack.count(): + old_page = self.stack.widget(0) + self.stack.removeWidget(old_page) + old_page.deleteLater() + + self.main_page = self._create_main_page() + self.general_page = self._create_general_page() + self.appearance_page = self._create_appearance_page() + self.account_page = self._create_account_page() + self.about_page = self._create_about_page() + for page in ( + self.main_page, + self.general_page, + self.appearance_page, + self.account_page, + self.about_page, + ): + self.stack.addWidget(page) + self.stack.setCurrentIndex(min(current_page, self.stack.count() - 1)) + self.language_combo.currentIndexChanged.connect(self._mark_settings_dirty) + self.client_id_edit.textEdited.connect(self._mark_settings_dirty) + self.theme_combo.currentIndexChanged.connect(self._apply_selected_theme) + def _create_main_page(self) -> QWidget: page = QWidget() page.setObjectName("settingsMainPage") @@ -132,16 +167,17 @@ class SettingsPage(QWidget): scroll_layout.setContentsMargins(24, 20, 24, 24) scroll_layout.setSpacing(12) - title = QLabel("Settings") + tr = self.app.tr_text + title = QLabel(tr("Settings")) title.setObjectName("settingsPageTitle") scroll_layout.addWidget(title) scroll_layout.addSpacing(8) pages = ( - ("General", "Language and launcher directories", "general_page"), - ("Appearance", "Launcher theme", "appearance_page"), - ("Account", "Microsoft authentication settings", "account_page"), - ("About", "Version and project information", "about_page"), + (tr("General"), tr("Language and launcher directories"), "general_page"), + (tr("Appearance"), tr("Launcher theme"), "appearance_page"), + (tr("Account"), tr("Microsoft authentication settings"), "account_page"), + (tr("About"), tr("Version and project information"), "about_page"), ) for title_text, description, page_name in pages: button = SettingsItem(title_text, description) @@ -157,21 +193,22 @@ class SettingsPage(QWidget): return page def _create_general_page(self) -> QWidget: - page = SettingsSubPage("General", self._go_back) + tr = self.app.tr_text + page = SettingsSubPage(tr("General"), self._go_back) current = self.settings.get("general", GeneralSettings) - page.content_layout.addWidget(QLabel("Current launcher working directory")) + page.content_layout.addWidget(QLabel(tr("Current launcher working directory"))) launcher_dir = QLineEdit(self.app.work_dir.as_posix()) launcher_dir.setReadOnly(True) page.content_layout.addWidget(launcher_dir) - page.content_layout.addWidget(QLabel("Current program directory")) + page.content_layout.addWidget(QLabel(tr("Current program directory"))) program_dir = QLineEdit(self.app.program_dir.as_posix()) program_dir.setReadOnly(True) page.content_layout.addWidget(program_dir) page.content_layout.addSpacing(8) - page.content_layout.addWidget(QLabel("Language")) + page.content_layout.addWidget(QLabel(tr("Language"))) self.language_combo = self._choice_combo( "general", "language", LANGUAGE_LABELS, current.language ) @@ -179,10 +216,11 @@ class SettingsPage(QWidget): return page def _create_appearance_page(self) -> QWidget: - page = SettingsSubPage("Appearance", self._go_back) + tr = self.app.tr_text + page = SettingsSubPage(tr("Appearance"), self._go_back) current = self.settings.get("appearance", AppearanceSettings) - page.content_layout.addWidget(QLabel("Theme")) + page.content_layout.addWidget(QLabel(tr("Theme"))) self.theme_combo = self._choice_combo( "appearance", "theme", THEME_LABELS, current.theme ) @@ -190,30 +228,32 @@ class SettingsPage(QWidget): return page def _create_account_page(self) -> QWidget: - page = SettingsSubPage("Account", self._go_back) + tr = self.app.tr_text + page = SettingsSubPage(tr("Account"), self._go_back) current = self.settings.get("account", AccountSettings) - page.content_layout.addWidget(QLabel("Microsoft OAuth Client ID")) + page.content_layout.addWidget(QLabel(tr("Microsoft OAuth Client ID"))) self.client_id_edit = QLineEdit(str(current.client_id)) self.client_id_edit.setPlaceholderText( "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ) page.content_layout.addWidget(self.client_id_edit) - help_label = QLabel( + help_label = QLabel(tr( "The Client ID must belong to an Azure application configured " "for Microsoft device-code authentication." - ) + )) help_label.setObjectName("settingsHelpText") help_label.setWordWrap(True) page.content_layout.addWidget(help_label) return page def _create_about_page(self) -> QWidget: - page = SettingsSubPage("About", self._go_back) + tr = self.app.tr_text + page = SettingsSubPage(tr("About"), self._go_back) brand_label = QLabel(LAUNCHER_NAME) brand_label.setObjectName("settingsBrand") - version_label = QLabel(f"Version: {LAUNCHER_VERSION}") + version_label = QLabel(tr("Version: {version}", version=LAUNCHER_VERSION)) version_label.setObjectName("settingsVersion") description = QPlainTextEdit(LAUNCHER_DESCRIPTION) description.setObjectName("settingsDescription") @@ -233,7 +273,10 @@ class SettingsPage(QWidget): ) -> QComboBox: combo = QComboBox() for value in self.settings.choices(section, field): - combo.addItem(labels.get(value, str(value)), value) + combo.addItem( + self.app.tr_text(labels.get(value, str(value))), + value, + ) index = combo.findData(current) combo.setCurrentIndex(max(index, 0)) return combo @@ -257,10 +300,12 @@ class SettingsPage(QWidget): self.settings.save() except Exception as error: if show_error: - QMessageBox.critical(self, "Settings error", str(error)) + QMessageBox.critical(self, self.app.tr_text("Settings error"), str(error)) return False self._settings_dirty = False + selected_language = data["general"]["language"] + self.app.apply_language(selected_language) return True def _open_page(self, page: QWidget) -> None: diff --git a/pages/test.py b/pages/test.py index b73d96b..4abad3f 100644 --- a/pages/test.py +++ b/pages/test.py @@ -43,31 +43,31 @@ class TestPage(QWidget): self.resize(800, 600) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) - self.button = QPushButton("Fetch Version Data") + self.button = QPushButton(self.app.tr_text("Fetch Version Data")) self.button.setObjectName("ActionButton") self.button.clicked.connect(self.run_test) - self.button_2 = QPushButton("Download client") + self.button_2 = QPushButton(self.app.tr_text("Download client")) self.button_2.setObjectName("ActionButton") self.button_2.clicked.connect(self.run_download_client) - self.button_3 = QPushButton("Download libraries") + self.button_3 = QPushButton(self.app.tr_text("Download libraries")) self.button_3.setObjectName("ActionButton") self.button_3.clicked.connect(self.run_download_libraries) - self.button_4 = QPushButton("Refresh versions") + self.button_4 = QPushButton(self.app.tr_text("Refresh versions")) self.button_4.setObjectName("ActionButton") self.button_4.clicked.connect(self.load_all_version) - self.button_5 = QPushButton("Download assets") + self.button_5 = QPushButton(self.app.tr_text("Download assets")) self.button_5.setObjectName("ActionButton") self.button_5.clicked.connect(self.download_assets) - self.button_6 = QPushButton("Parse Arguments") + self.button_6 = QPushButton(self.app.tr_text("Parse Arguments")) self.button_6.setObjectName("ActionButton") self.button_6.clicked.connect(self.parse_argument) - self.button_7 = QPushButton("Launch Game! (Offline)") + self.button_7 = QPushButton(self.app.tr_text("Launch Game! (Offline)")) self.button_7.setObjectName("ActionButton") self.button_7.clicked.connect(self.launch_offline_game) diff --git a/resources/styles/settings.qss b/resources/styles/settings.qss index cbc8780..58d00fd 100644 --- a/resources/styles/settings.qss +++ b/resources/styles/settings.qss @@ -82,3 +82,38 @@ QLabel#settingsVersion { QPlainTextEdit#settingsDescription { 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; +} diff --git a/resources/translations/en.json b/resources/translations/en.json new file mode 100644 index 0000000..9956ed4 --- /dev/null +++ b/resources/translations/en.json @@ -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." +} diff --git a/resources/translations/zh-TW.json b/resources/translations/zh-TW.json new file mode 100644 index 0000000..8ab9371 --- /dev/null +++ b/resources/translations/zh-TW.json @@ -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.": "為了讓所有頁面套用語言更新,建議在修改後重新啟動啟動器。" +} diff --git a/window.py b/window.py index 40de299..43e795e 100644 --- a/window.py +++ b/window.py @@ -116,13 +116,17 @@ class LauncherMainWindow(QMainWindow): "Suggestions are always welcome!") self.icon_label = QLabel() 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_2 = QLabel(message) + self.dev_info_2 = QLabel(self.app.tr_text(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.repo_url_label = QLabel(f"Main repo") + self.repo_url_label = QLabel( + f'{self.app.tr_text("Main repo")}' + ) self.repo_url_label.setObjectName("repo_url_label") self.repo_url_label.setOpenExternalLinks(True) @@ -130,7 +134,7 @@ class LauncherMainWindow(QMainWindow): image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png")) self.icon_label.setPixmap(image.scaled(300, 300)) else: - self.icon_label.setText("Ouch. The icon is missing.") + self.icon_label.setText(self.app.tr_text("Ouch. The icon is missing.")) # Dialog 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.addStretch() - # test page - self.test_page = TestPage( - app=self.app, - parent=self, - widget_manager=self.app.widget_manager - ) + if self.app.debug: + # test page + self.test_page = TestPage( + app=self.app, + parent=self, + widget_manager=self.app.widget_manager + ) self.launch_profile_page = LaunchProfilePage( app=self.app, @@ -172,7 +177,8 @@ class LauncherMainWindow(QMainWindow): # Add 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.settings_page) self.central.addWidget(self.create_profile_page) @@ -190,18 +196,31 @@ class LauncherMainWindow(QMainWindow): # Toolbar # Test Window btn - self.open_test_page = self.toolbar.add_button(" Tests", icon=self.app.get_icon(Path(self.app.icon_dir, "debug.png"))) - self.open_test_page.setObjectName("open_test_page") + if self.app.debug: + 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 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)) # 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) - 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 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 # Profile btn - self.open_create_profile_page = self.toolbar.add_button("Create Profile", - icon=self.app.get_icon(Path(self.app.icon_dir, "add.png"))) + self.open_create_profile_page = self.toolbar.add_button("Create Profile") + self.app.set_reloadable_toolbutton_icon( + self.open_create_profile_page, + Path(self.app.icon_dir, "add.png"), + ) # manage profile self.open_manage_profile_page = self.toolbar.add_button( @@ -220,7 +242,8 @@ class LauncherMainWindow(QMainWindow): # Bind tool buttons 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_launch_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) ) - self.open_test_page.clicked.connect( - lambda: self.set_current_page(self.test_page, self.open_test_page) - ) + if self.app.debug: + 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( 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.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'{tr("Main repo")}' + ) + 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) def ask_request(self, context: AskForRequest): @@ -309,8 +358,8 @@ class LauncherMainWindow(QMainWindow): @Slot(dict) def show_and_print_error(self, context: dict): error = context.get("error", None) - title = context.get("title", "Error") - message = context.get("message", "No content") + title = context.get("title", self.app.tr_text("Error")) + message = context.get("message", self.app.tr_text("No content")) if isinstance(error, list): self.app.logger.error("".join(error)) @@ -335,15 +384,15 @@ class LauncherMainWindow(QMainWindow): @Slot(str) def show_error(self, message): - messagebox.error(self, "Error", message=message) + messagebox.error(self, self.app.tr_text("Error"), message=message) @Slot(str) def show_warning(self, widget): - messagebox.warning(self, "Warning", message=widget) + messagebox.warning(self, self.app.tr_text("Warning"), message=widget) @Slot(str) 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): self.central.setCurrentWidget(page)