From a11a1078cc45d3345f3a3571949e9358bbdc65c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Wed, 28 Oct 2026 22:43:14 +0800 Subject: [PATCH 1/5] fix: address PR review findings, repair builds and add prioritized BMCL mirrors --- .github/workflows/build.yml | 31 +- .github/workflows/ci.yml | 4 +- docs/DOWNLOAD_SOURCES.md | 48 ++ docs/TEST_REPORT.md | 28 ++ packaging/build_nuitka.py | 22 +- pyproject.toml | 4 +- src/apeiron/__main__.py | 3 + src/apeiron/auth/microsoft.py | 7 +- src/apeiron/auth/service.py | 4 +- src/apeiron/core/archive.py | 33 +- src/apeiron/core/config.py | 19 +- src/apeiron/core/http.py | 48 +- src/apeiron/core/paths.py | 8 +- src/apeiron/i18n/apeiron_en_US.qm | Bin 19909 -> 20674 bytes src/apeiron/i18n/apeiron_en_US.ts | 98 ++-- src/apeiron/i18n/apeiron_zh_CN.qm | Bin 14647 -> 15190 bytes src/apeiron/i18n/apeiron_zh_CN.ts | 98 ++-- src/apeiron/instances/manager.py | 22 +- src/apeiron/loaders/forge.py | 16 +- src/apeiron/mc/installer.py | 30 +- src/apeiron/mc/launch.py | 8 +- src/apeiron/mc/manifest.py | 8 +- src/apeiron/mc/version.py | 5 +- src/apeiron/modpack/curseforge.py | 25 +- src/apeiron/modpack/mrpack.py | 46 +- src/apeiron/modpack/service.py | 158 +++--- src/apeiron/net/downloader.py | 81 +++- src/apeiron/net/sources.py | 50 ++ src/apeiron/services/app_context.py | 4 +- src/apeiron/services/launcher.py | 37 +- src/apeiron/ui/app.py | 12 +- src/apeiron/ui/backend.py | 88 +++- src/apeiron/ui/qml/pages/ContentPage.qml | 19 +- src/apeiron/ui/qml/pages/LibraryPage.qml | 40 +- src/apeiron/ui/qml/pages/SettingsPage.qml | 4 +- tests/test_modpack.py | 23 +- tests/test_review_regressions.py | 560 ++++++++++++++++++++++ tools/build_translations.py | 2 + 38 files changed, 1383 insertions(+), 310 deletions(-) create mode 100644 docs/DOWNLOAD_SOURCES.md create mode 100644 src/apeiron/net/sources.py create mode 100644 tests/test_review_regressions.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2c1a99..465f18e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,7 @@ name: Build on: push: tags: ["v*"] + branches: ["main"] pull_request: paths: - "src/**" @@ -41,6 +42,7 @@ jobs: - os: windows-2022 artifact: apeiron-windows-x86_64 runs-on: ${{ matrix.os }} + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -63,7 +65,8 @@ jobs: run: | # The standalone directory is named after the compiled entry script, # so locate the binary instead of hard-coding it. - BIN=$(find dist -maxdepth 2 -type f -name "Apeiron*" | head -1) + set -euo pipefail + BIN=$(find dist -maxdepth 2 -type f -name "ApeironLauncher*" | head -1) test -n "$BIN" || { echo "no artifact produced"; ls -R dist | head -50; exit 1; } echo "artifact: $BIN" "$BIN" --version @@ -101,3 +104,29 @@ jobs: path: out/* if-no-files-found: error retention-days: 14 + + release: + name: Publish release archives + if: startsWith(github.ref, 'refs/tags/v') + needs: package + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: apeiron-* + path: release-artifacts + merge-multiple: true + - name: Publish verified packages + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" release-artifacts/* --clobber + else + gh release create "$GITHUB_REF_NAME" release-artifacts/* --verify-tag --generate-notes + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f358ad0..2a0bc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: cache: pip - name: Install Qt runtime libraries (Linux) if: runner.os == 'Linux' - run: sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 - name: Install dependencies # Single line on purpose: the default shell on Windows runners is pwsh, # where a trailing backslash is not a line continuation. @@ -143,7 +143,7 @@ jobs: python-version: "3.12" cache: pip - name: Install Qt runtime libraries - run: sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/docs/DOWNLOAD_SOURCES.md b/docs/DOWNLOAD_SOURCES.md new file mode 100644 index 0000000..61307b9 --- /dev/null +++ b/docs/DOWNLOAD_SOURCES.md @@ -0,0 +1,48 @@ +# Download sources + +Minecraft downloads use this fixed priority, enabled by the application composition root: + +1. `https://bsmirror.hkmc.online` +2. `https://mirror.hkmc.online` +3. `https://bmclapi2.bangbang93.com` +4. The original official URL. + +Routing follows the [BMCLAPI compatibility documentation](https://bmclapidoc.bangbang93.com/): +Mojang metadata/client paths are preserved, assets use `/assets`, Maven libraries use +`/maven`, and Fabric metadata uses `/fabric-meta`. NeoForge release Maven paths strip +`/releases` before being placed under `/maven`. Quilt remains on its official endpoints +because BMCLAPI documents its Quilt API as unavailable. Java provisioning uses Adoptium's +official API and SHA-256 validation. + +Only explicitly recognized public download hosts are rewritten. Microsoft/Xbox/Minecraft +account APIs, CurseForge API calls, Modrinth downloads and arbitrary modpack URLs retain +their original destinations. HTTPS is required, including after streaming redirects. + +A failed or corrupt mirror advances to the next source. Each file attempt starts a new +`.part` file, verifies size and all supplied SHA-1/SHA-512 digests, and atomically commits +only verified bytes. Download progress counts successful bytes once and removes bytes +from discarded attempts. Independent batches are serialized so their cancellation and +progress state cannot overwrite one another. Version JSON is checked against the +manifest's SHA-1 before parsing or persistence. + +Mirrors do not remove the need for a licensed account or change authentication. + +## CurseForge configuration + +Set `APEIRON_CURSEFORGE_API_KEY` in the launcher's environment to your own API key before +starting Apeiron. No shared key is embedded, persisted in settings, or sent to mirrors. +The UI reports this configuration path when an import requires a key. + +## CI and release builds + +CI validates pull requests and `main`; package builds run for pull requests affecting +application/build files, pushes to `main`, version tags and manual dispatch. All three +platform packages must pass their packaged self-check. Version tags (`v*`) publish the +three archives in a GitHub Release after packaging succeeds; ordinary PR builds only +upload workflow artifacts. Release permissions are granted only to the tag release job. + +The executable is named `ApeironLauncher` (`ApeironLauncher.exe` on Windows). This avoids +the case-insensitive filesystem collision between an `Apeiron` executable and the +`apeiron/` extension/data directory on macOS. Nuitka collects `_logcore` as an extension +module, not as an untracked data file. Windows offscreen checks explicitly discover the +installed Windows fonts; QML warnings remain fatal. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index dc18969..48c1430 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -276,3 +276,31 @@ QT_QPA_PLATFORM=offscreen python tools/gui_smoke.py /tmp/gui-root python packaging/build_nuitka.py --output-dir dist xvfb-run -a python tools/e2e_minecraft.py --version 1.20.1 --data-dir /tmp/e2e ``` + +## 13. PR #1 review remediation (2026-10-28) + +The follow-up repair covers the 35 inline review findings, including the previously +outdated artifact-directory finding. Regression coverage is in +`tests/test_review_regressions.py`; invalid historical modpack hash fixtures now contain +real digests and successful loader installation is explicitly stubbed. + +Verified locally on Linux / Python 3.12 / PySide6 6.9.3: + +- C11 harness: 44 checks, 0 failures. +- Ruff and mypy: clean after remediation. +- Mirror mapping, priority, corrupt-source fallback, SHA-512-only downloads, streaming + downgrade rejection and OAuth HTTP 400 protocol states are exercised with controlled + responses rather than relying on live network availability. +- Regression tests cover settings corruption, macOS classifier names, failed world + restore, symlink export, bounded overrides, legacy asset layouts, loader rollback, + content categories, CurseForge records, launch failures/session duration, managed Java + persistence, file URL conversion and shutdown settings. + +Historical sections above describe the previous revision. The latest macOS failure was +identified as `Apeiron`/`apeiron` filesystem collision; Windows failed its otherwise +successful package self-check because offscreen Qt did not discover the Windows fonts. +Both causes are addressed in this revision. Cross-platform package results must be read +from the follow-up PR's Actions runs; local Linux tests do not certify Windows/macOS. +Live mirror reachability and a licensed Microsoft sign-in are not certified by these +mocked protocol tests. The local network returned HTTP 503 for the bsmirror manifest +probe. See `docs/DOWNLOAD_SOURCES.md` for routing and configuration. diff --git a/packaging/build_nuitka.py b/packaging/build_nuitka.py index 8542366..c27680e 100644 --- a/packaging/build_nuitka.py +++ b/packaging/build_nuitka.py @@ -99,43 +99,41 @@ def main() -> int: "--standalone", "--assume-yes-for-downloads", "--enable-plugin=pyside6", - "--include-qt-plugins=platforms,imageformats,iconengines,tls", + "--include-qt-plugins=platforms,imageformats,iconengines,tls,qml", "--include-package=apeiron", "--include-package-data=apeiron", "--noinclude-dlls=*.debug", - # The extension is shipped as a data file; letting the DLL scanner copy - # it again collides with that path (NotADirectoryError on macOS). - "--noinclude-dlls=_logcore*", f"--output-dir={out_dir}", - f"--output-filename=Apeiron{'.exe' if platform.system() == 'Windows' else ''}", + f"--output-filename=ApeironLauncher{'.exe' if platform.system() == 'Windows' else ''}", "--company-name=Zerexa Ltd", "--product-name=Apeiron", "--file-version=0.1.0", "--product-version=0.1.0", "--file-description=Apeiron Minecraft launcher", "--copyright=Zerexa Ltd", - # The extension is compiled C, not Python source: copy it as data. - f"--include-data-files={extension}=apeiron/{extension.name}", + "--include-module=apeiron._logcore", "packaging/entry.py", ] if args.onefile: - command.insert(2, "--onefile") + command.insert(3, "--onefile") if args.jobs: - command.insert(2, f"--jobs={args.jobs}") + command.insert(3, f"--jobs={args.jobs}") run(command) # 4. verify the artifact ------------------------------------------------ if args.onefile: - binary = out_dir / ("Apeiron.exe" if platform.system() == "Windows" else "Apeiron.bin") + binary = out_dir / ( + "ApeironLauncher.exe" if platform.system() == "Windows" else "ApeironLauncher" + ) else: suffix = ".exe" if platform.system() == "Windows" else "" for dist_name in ("entry.dist", "__main__.dist"): - candidate = out_dir / dist_name / f"Apeiron{suffix}" + candidate = out_dir / dist_name / f"ApeironLauncher{suffix}" if candidate.exists(): binary = candidate break else: - binary = out_dir / "entry.dist" / f"Apeiron{suffix}" + binary = out_dir / "entry.dist" / f"ApeironLauncher{suffix}" if not binary.exists(): matches = list(out_dir.rglob("Apeiron*")) diff --git a/pyproject.toml b/pyproject.toml index 825591a..9a92ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Programming Language :: C", "Topic :: Games/Entertainment", ] -dependencies = ["PySide6-Essentials>=6.7,<6.10"] +dependencies = ["PySide6-Essentials>=6.8,<6.10", "PySide6-Addons>=6.8,<6.10"] [project.optional-dependencies] dev = [ @@ -51,7 +51,7 @@ zip-safe = false where = ["src"] [tool.setuptools.package-data] -apeiron = ["qml/**/*.qml", "qml/**/qmldir", "i18n/*.qm", "i18n/*.ts", "resources/*"] +apeiron = ["ui/qml/**/*.qml", "ui/qml/**/qmldir", "i18n/*.qm", "i18n/*.ts", "resources/*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/apeiron/__main__.py b/src/apeiron/__main__.py index 2c3517f..0e92d0d 100644 --- a/src/apeiron/__main__.py +++ b/src/apeiron/__main__.py @@ -31,6 +31,9 @@ def _self_check() -> int: report: dict[str, object] = {"version": __version__, "checks": {}, "ok": False} checks: dict[str, object] = report["checks"] # type: ignore[assignment] + from apeiron.ui.app import configure_platform_fonts + + configure_platform_fonts() app = QGuiApplication.instance() or QGuiApplication([sys.argv[0]]) app.setApplicationName("Apeiron") QQuickStyle.setStyle("Material") diff --git a/src/apeiron/auth/microsoft.py b/src/apeiron/auth/microsoft.py index 3c018b4..d2eb0e7 100644 --- a/src/apeiron/auth/microsoft.py +++ b/src/apeiron/auth/microsoft.py @@ -264,7 +264,7 @@ def _session_from_microsoft( access_token=mc_token, user_type="msa", xuid=xuid, - client_id=self.client_id, + client_id=resolve_client_id(self.client_id), verified=True, expires_at=time.time() + expires_in, refresh_token=refresh_token, @@ -355,10 +355,9 @@ def _form_post( method="POST", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, + accepted_statuses=frozenset({400}) if allow_error_body else frozenset(), ) - except NetworkError as exc: - if allow_error_body and "HTTP 400" in exc.message: - return {"error": "invalid_request", "error_description": exc.detail or ""} + except NetworkError: raise return response.json() diff --git a/src/apeiron/auth/service.py b/src/apeiron/auth/service.py index 2045fb3..beb8fd3 100644 --- a/src/apeiron/auth/service.py +++ b/src/apeiron/auth/service.py @@ -22,7 +22,7 @@ from typing import Any from apeiron.auth.base import Session -from apeiron.auth.microsoft import DeviceCodeInfo, MicrosoftAuth +from apeiron.auth.microsoft import DeviceCodeInfo, MicrosoftAuth, resolve_client_id from apeiron.auth.offline import create_offline_profile, rename_profile, validate_name from apeiron.auth.store import CredentialStore, StoreInfo, store_info from apeiron.core.errors import AccountError, AuthExpiredError @@ -176,7 +176,7 @@ def refresh(self, account_id: str) -> Session: access_token=str(stored.get("access_token", "")), user_type="msa", xuid=str(stored.get("xuid", account.microsoft_id)), - client_id=self.microsoft.client_id, + client_id=resolve_client_id(self.microsoft.client_id), verified=True, expires_at=float(stored.get("expires_at", 0)), refresh_token=str(stored.get("refresh_token", "")), diff --git a/src/apeiron/core/archive.py b/src/apeiron/core/archive.py index 3fb8cd8..44f1835 100644 --- a/src/apeiron/core/archive.py +++ b/src/apeiron/core/archive.py @@ -10,7 +10,6 @@ from __future__ import annotations import os -import shutil import tarfile import zipfile from collections.abc import Iterable @@ -44,6 +43,7 @@ def extract_zip( *, members: Iterable[str] | None = None, max_total_bytes: int = 8 * 1024**3, + prefixes: tuple[str, ...] = (), ) -> list[Path]: """Extract a zip safely and return the written paths.""" destination.mkdir(parents=True, exist_ok=True) @@ -57,7 +57,20 @@ def extract_zip( continue if info.is_dir(): continue - target = _validate_member(info.filename, destination) + name = info.filename + if prefixes: + prefix = next( + ( + p.rstrip("/") + "/" + for p in prefixes + if name.startswith(p.rstrip("/") + "/") + ), + None, + ) + if prefix is None: + continue + name = name[len(prefix) :] + target = _validate_member(name, destination) mode = info.external_attr >> 16 if mode and (mode & 0o170000) == 0o120000: raise PermissionError_(f"archive entry is a symlink: {info.filename}") @@ -68,7 +81,12 @@ def extract_zip( ) target.parent.mkdir(parents=True, exist_ok=True) with zf.open(info) as src, target.open("wb") as dst: - shutil.copyfileobj(src, dst) + copied = 0 + while block := src.read(262144): + copied += len(block) + if copied > info.file_size or copied > max_total_bytes: + raise ArchiveError("archive entry exceeds declared size") + dst.write(block) written.append(target) except zipfile.BadZipFile as exc: raise ArchiveError(f"not a valid zip archive: {archive.name}", detail=str(exc)) from exc @@ -122,7 +140,7 @@ def zip_directory( tmp = target.with_suffix(target.suffix + ".part") with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf: for path in sorted(source.rglob("*")): - if not path.is_file(): + if not safe_source_file(path, source): continue relative = path.relative_to(source).as_posix() if any(relative == e or relative.startswith(e + "/") for e in excluded): @@ -141,3 +159,10 @@ def is_within(path: Path, parent: Path) -> bool: except OSError: return False return resolved == root or root in resolved.parents + + +def safe_source_file(path: Path, root: Path) -> bool: + """Only export regular files whose entire path stays in the source tree.""" + if path.is_symlink() or any((root / p).is_symlink() for p in path.relative_to(root).parents): + return False + return path.is_file() and is_within(path, root) diff --git a/src/apeiron/core/config.py b/src/apeiron/core/config.py index 0439ddb..624067f 100644 --- a/src/apeiron/core/config.py +++ b/src/apeiron/core/config.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import math import os import tempfile from dataclasses import asdict, dataclass, field, fields @@ -76,7 +77,20 @@ class Settings: def normalised(self) -> Settings: """Return a copy with every field inside its supported range.""" clone = Settings(**{f.name: getattr(self, f.name) for f in fields(Settings)}) - clone.extra = dict(self.extra) + defaults = Settings() + for f in fields(Settings): + value = getattr(clone, f.name) + default = getattr(defaults, f.name) + if isinstance(default, bool): + valid = isinstance(value, bool) + elif isinstance(default, (int, float)): + valid = isinstance(value, (int, float)) and not isinstance(value, bool) + valid = valid and math.isfinite(value) + else: + valid = isinstance(value, type(default)) + if not valid: + setattr(clone, f.name, default) + clone.extra = dict(clone.extra) if clone.language not in SUPPORTED_LANGUAGES: clone.language = "system" if clone.theme not in SUPPORTED_THEMES: @@ -198,7 +212,8 @@ def migrate(raw: dict[str, Any]) -> dict[str, Any]: Version 1 stored ``lang`` and ``dark_mode``; version 2 introduced the tri-state ``language`` (``system``) and ``theme``. """ - version = int(raw.get("schema_version", 1) or 1) + value = raw.get("schema_version", 1) + version = value if isinstance(value, int) and not isinstance(value, bool) else 1 if version < 2: lang = raw.pop("lang", None) if lang in ("zh_CN", "en_US"): diff --git a/src/apeiron/core/http.py b/src/apeiron/core/http.py index b4720c0..179ed39 100644 --- a/src/apeiron/core/http.py +++ b/src/apeiron/core/http.py @@ -13,6 +13,7 @@ from __future__ import annotations +import hashlib import json import random import socket @@ -26,8 +27,9 @@ from typing import Any from apeiron.core import __version__ as apeiron_version -from apeiron.core.errors import NetworkError, TimeoutError_ +from apeiron.core.errors import IntegrityError, NetworkError, TimeoutError_ from apeiron.core.logging import get_logger +from apeiron.net.sources import download_candidates log = get_logger("http") @@ -49,6 +51,7 @@ class HttpConfig: backoff_base: float = 0.5 backoff_max: float = 20.0 verify_tls: bool = True + use_mirrors: bool = False allow_insecure: bool = False # loopback HTTP only, used by tests @@ -101,7 +104,7 @@ def validate_url(url: str, *, allow_insecure: bool = False) -> str: return url # -- core request ------------------------------------------------------- - def request( + def _request( self, url: str, *, @@ -111,6 +114,7 @@ def request( timeout: float | None = None, max_bytes: int = MAX_JSON_BYTES, retries: int | None = None, + accepted_statuses: frozenset[int] = frozenset(), ) -> Response: self.validate_url(url, allow_insecure=self.config.allow_insecure) attempts = self.config.retries if retries is None else retries @@ -126,6 +130,7 @@ def request( data=data, timeout=timeout, max_bytes=max_bytes, + accepted_statuses=accepted_statuses, ) except _Retryable as exc: last_error = exc.__cause__ or exc @@ -149,6 +154,30 @@ def request( detail=str(last_error), ) from last_error + def request( + self, url: str, *, expected_sha1: str = "", validate_json: bool = False, **kwargs: Any + ) -> Response: + candidates = self.candidates(url) if kwargs.get("method", "GET") == "GET" else (url,) + last_error: Exception | None = None + for candidate in candidates: + try: + response = self._request(candidate, **kwargs) + if ( + expected_sha1 + and hashlib.sha1(response.body).hexdigest() != expected_sha1.lower() + ): + raise IntegrityError("version metadata checksum mismatch") + if validate_json: + response.json() + return response + except (NetworkError, IntegrityError) as exc: + last_error = exc + assert last_error is not None + raise last_error + + def candidates(self, url: str) -> tuple[str, ...]: + return download_candidates(url) if self.config.use_mirrors else (url,) + def _backoff(self, attempt: int) -> float: raw = self.config.backoff_base * (2**attempt) capped = min(raw, self.config.backoff_max) @@ -163,6 +192,7 @@ def _once( data: bytes | None, timeout: float, max_bytes: int, + accepted_statuses: frozenset[int] = frozenset(), ) -> Response: merged = {"User-Agent": user_agent(), "Accept-Encoding": "identity"} if headers: @@ -184,6 +214,15 @@ def _once( body=body, ) except urllib.error.HTTPError as exc: + if exc.code in accepted_statuses: + with exc: + self._check_redirect_origin(url, exc.geturl()) + body = exc.read(max_bytes + 1) + if len(body) > max_bytes: + raise _Fatal( + NetworkError("HTTP error response exceeds size limit") + ) from exc + return Response(exc.geturl(), exc.code, dict(exc.headers), body) retry_after = _parse_retry_after( exc.headers.get("Retry-After") if exc.headers else None ) @@ -228,7 +267,7 @@ def _check_redirect_origin(original: str, final: str) -> None: # -- convenience -------------------------------------------------------- def get_json(self, url: str, **kwargs: Any) -> Any: - return self.request(url, **kwargs).json() + return self.request(url, validate_json=True, **kwargs).json() def get_bytes(self, url: str, **kwargs: Any) -> bytes: return self.request(url, **kwargs).body @@ -247,11 +286,14 @@ def stream( timeout = self.config.timeout if timeout is None else timeout try: with urllib.request.urlopen(request, timeout=timeout, context=self._context) as raw: + self._check_redirect_origin(url, raw.geturl()) while True: chunk = raw.read(chunk_size) if not chunk: break yield chunk + except _Fatal as exc: + raise exc.error from exc except urllib.error.HTTPError as exc: raise NetworkError(f"HTTP {exc.code} for {_safe(url)}", detail=str(exc.reason)) from exc except TimeoutError as exc: diff --git a/src/apeiron/core/paths.py b/src/apeiron/core/paths.py index 2f21254..9758bed 100644 --- a/src/apeiron/core/paths.py +++ b/src/apeiron/core/paths.py @@ -137,9 +137,11 @@ def ensure(self) -> None: """ for directory in self.all_directories(): try: - directory.mkdir(parents=True, exist_ok=True) - if os.name == "posix": - os.chmod(directory, 0o700) + try: + directory.mkdir(mode=0o700, parents=True, exist_ok=False) + except FileExistsError: + if not directory.is_dir(): + raise except OSError as exc: # pragma: no cover - depends on the host raise OSError(f"cannot create {directory}: {exc}") from exc diff --git a/src/apeiron/i18n/apeiron_en_US.qm b/src/apeiron/i18n/apeiron_en_US.qm index 600c02a05c90f00abeef1e783a8bebb61464752d..24c903e554dd1f4dc03c85b8fe1e01b87d7a7ce8 100644 GIT binary patch delta 1978 zcmeHH2~Sj47(KJ^Z$trwSa`S#!)TEK5n)sm0WDS$#2SrL8FlRNCc+?@COQ_2QEIhj z+=!)!#4T!SrPVf|#)Z1n)>bgK8f{(CT9>$`YLy-s)3pDgnY=mo-goc!o$q|-zUGax zBXzP*qGWqkM~C}IO^5QS4M<-KmG&?&WII&0Yk=Jf)tCmL=M$*1_&#X_R6Az^aq$S< zSp?_{gBaTn5x=AW>SY+a?ii3(gGnzR17j;uxc?gT^MJ5{GVhXuK=MV|)&to< zicQ|5pf6yGloy8SfP`oAvRf+wYrDLvH|o0ls8k6Iw#d&Ug|V>v@(VWw z68uX3Y}rXa7oaFPy99`^C>97lGQOrLj~f8wtXBBa7!YF=XRoHP@&`)o*eIZ@MVbG5 z29RB#e8-Rrq@GiH+8;3yl*=x(vJkg&Lv|BjY*RLVF^0+vQ~q>|=Pa!%<$^UN3e|vP z8-V^-iSZmH#;As8Rs#c0tESgcqES_<#h=e$1zS}sGMjU~b-%`&s>(wcJUXjjob$NZqe;Ccc?KUYt` z#OUfD)w|bhX5)9N8^28evL>o;oF?6@%j!GP_sA+w(`Dx@mie1zP>Ykg9@7lAbAQkA zo#=<{lxAk(IUxR_X6gL*fo^*>)oCGY)Ok%!Y9fi`X=<}bY~T&e87uWkS);kTEe24% z(6m=D;GnD8_{Vn`P#(m7m$kWjNiefq>()ilNl&#OR$ECx)NWlS0g=14jcYFgeJve0 znu6A953D#r$GC%-TCbZp`~x84PhIhll|WRruDXDY&56@(Or%nodvse59|a7)Af`13 zCMd%Fwj^EKE-s62>-)SlhwWZRoKL*2w>8z%seANOR?_idUcGZyD9~eH5YzkUXP?do zx_k87E3UJjF8x7m1qJSC1sPVhnjgHZXdcVcO&$Sm<`cB8iEUw1$rr6lP>`z^n|9jNW6o*i8Kr5)6Or zKTduFjG84ZGi!6et_=708fsizMcT>hjg<z8)c+tuL@%J zHsi@n0^Ro9cW(j1LB~@7;<`T`O8Se_^st9s=m{iOIxT z(?TDyUyW(?xZb?bX*$!TeJmqmx#`h8$}ZSU&nM(@KGgJLQw9T62zbEu8TJaMN0)i; z<^^%EPY?^kSoVw{#%~dl<~2~)zCvEl7WU_S5Ys0Kp52Rh{-sdUK%yya!q>%7+*c+(oq zNtskC7J14gx5rWBEE01Z3moDsPm$9qW_Tp8ODc1U9?9(!U9&}>r%d#gl$Cl#?_8(Y zd7#9qVHQJ1U`~{|AvXGS*#B}+j`0m`i65zQVlk=H3F3d6)6vZicX;UlH-Aoc)BaDx a!X}kTucZ}VH_BOJ4_ry?-ryZJvD@Fp?HF1B delta 1361 zcmX9;dr(w$7(M&AclYkyy({9b0=kHRf-DFkD+;fS*$F{^6i65XA*lmM&4iHd148N`Oy>8*pCIhZ2cqwy-@bPM zL#`jAIuP{77(gsT#-}HMv4=7J#Uo(S4&>JV$oL}6UljxdY`~J1x0o*j+c6IqU5XQL z-h>CC6Vdr_3ot4kch0hK*kzUd-f|$mT{Tq*2gaXLxjVA~OSGzJT@&M;sA`X70*=?z z19Bn(Q?NSMA~Alsdc~b`AhuDxvF!y34pBE0FrTwmeY{{j5ZA3f@2~*l4C*VL3JG3N zKP~&&L!}{_cQ362g6x`=N--Jl(7YED1!U~k6pv#l?PT1L56%jG3)JvgtH1B-)S3P15VBYk>)Yk~^^+voI(} z+LpuqWw@m3Q8Xm&tWXuq(Z{~Tdbi)!&_;A@Lh5fi*$C@O>g1=$6}CXy}Oj@?Z6*$S=*PTSwjLM~r<| zd0AkUageM62J{$5=3D~?KQ^w}NFAfR8^9a+`A1LFMFH8^b z({&}u^xv!5e9tyL+mXg_r-BEQ=yshlIM+g%A%3*aRUC_bK;%s&dte*gukvGxTPZxS zn(;4`?Y?xXQ!9tvHqyvbj#lw}c%bt8t(W{?L{_&+Jnel-99 diff --git a/src/apeiron/i18n/apeiron_en_US.ts b/src/apeiron/i18n/apeiron_en_US.ts index 29b282e..1b1d355 100644 --- a/src/apeiron/i18n/apeiron_en_US.ts +++ b/src/apeiron/i18n/apeiron_en_US.ts @@ -229,100 +229,106 @@ ContentPage - - + + + Import modpack Import modpack - + + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + + + Modpacks (*.mrpack *.zip) Modpacks (*.mrpack *.zip) - - + + All files (*) All files (*) - - + + Add file Add file - + Mods and packs (*.jar *.zip) Mods and packs (*.jar *.zip) - + Content Content - + Mods, resource packs, shader packs and data packs for the selected instance. Mods, resource packs, shader packs and data packs for the selected instance. - + Select an instance Select an instance - + Choose an instance in the library to manage its content. Choose an instance in the library to manage its content. - + Mods Mods - + Resource packs Resource packs - + Shaders Shaders - + Data packs Data packs - + Nothing installed in this category. Nothing installed in this category. - + Remove Remove - + Mod loader Mod loader - + Current: %1 Current: %1 - + Forge and NeoForge installs run the vendor's official installer after verifying its published checksum. Forge and NeoForge installs run the vendor's official installer after verifying its published checksum. - + Open instance folder Open instance folder @@ -523,8 +529,8 @@ - - + + New instance New instance @@ -579,90 +585,100 @@ Details - + Memory (MB) Memory (MB) - + JVM arguments JVM arguments - + Resolution Resolution - + Save changes Save changes - + Install / verify files Install / verify files - + Duplicate Duplicate - + Backup worlds Backup worlds - + Export archive Export archive - + Delete instance Delete instance - + Delete instance? Delete instance? - + The instance directory and everything inside it will be removed. This cannot be undone. The instance directory and everything inside it will be removed. This cannot be undone. - + Name Name - + My world My world - + Minecraft version Minecraft version - + Mod loader Mod loader - + Vanilla Vanilla - + Loader versions are fetched from the projects' official metadata. Loader versions are fetched from the projects' official metadata. + + + Run loader installer? + Run loader installer? + + + + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + LogsPage diff --git a/src/apeiron/i18n/apeiron_zh_CN.qm b/src/apeiron/i18n/apeiron_zh_CN.qm index 98b32ee3e66fc66882cc82bf7ac5be7b94a4af59..94e052e6161e67dcd8073c7428001ada2d96b5fc 100644 GIT binary patch delta 1795 zcmdT^`BPI@7(Gc|_T&XA1QIoQ>`U0n;xcN{p@0hwRaUi95u#!tsUgAQ3dopFPHnog(xLVtL3-o5YM?>pz*bDzul zYuP#-@7wiywIg~Gm?sH9#5xEi$AG}&5GM2hkt~GCEr6F3!s+xqxDCR63jv)2Ui%6G z@k}?4ZHMN!i2$<&shc_g!zIjl`51_Oh+NkZS|0>cg$7Wp!Uvsmsa`C$q5udxfR3Eo zaKOh0eM1|85G@9NqQ<^1Uc~)UBC>{;CJX?gj`K?TG61%pXRB$abWfkwd3&R^U0k_dAARx{O$-orm(UJdw9;9nT$O~Q8bulBJ(oQD6j z?u>(!4+yN6Yk*M~1!ZhGF`gw@VF(2hrU}ZYP(dULF5jLA=tCJvs*aT0W3v880^;W} z^W;V#+{IW%hKT43rtWGtIdzBGJfn?>r82DtlgXLI%%uU^r|%OoWgCEiDq(2HW?+nt zFp^$^48qAN%{0+2T<`^16kRB+`X-MQR0$hWI!Sq(8`YW48$2C9@PTuL=i}%RX^o## zH&aNMqh8c|j@ZUM6AkJg5UDJ&$G!qm*&&YZGLfy*#W9Jr{*7TbI)Zb>3v+(~ zxc%afm#+krH^t3UMggi7;;ob7iAc0~+jJr}wqAT8itGtfi0|+AqsGzVk!mXF4@u@ojrLK5M!2>#5DrmE@^W%oooDv^s{(!D%K|5b?gKnNpz!O$eAusJA$rA`}fnd z`kpNCoh87V)r89lPs$S7b`tNKvYbW=yvko@+V4d%`Ou9KDYC`qvH*{I*`DevfA>PFq5+|#2Q21tA5DQ~Tzo6#!I zU}q42mwbNa&m?q{ywXgKgB#?Z2*{Wyr&GnKwdzXwwNCOc;A{Ck*D2z+QX#G(nQ^C` ziHzFe8>6UNOSD5yDOL+eM(7Sj(^(s}eNVAt)>A+eplIb0y~tcQ#(%0fvz?`|bt}%E zqFgAnit{CugIJlO|Ic0uWwPQ?7@fOvuhO%+o5sD$sLVJ(R8Baa(50*>CmbE1Tt6#- z?z7>2<@Lj_WCSUP9+2&_ROPeu3`+HY^7-~8DzKKtqqpf8idp5*?{uzkqrZjaa=l6R zA~$k&cHFWSDqPQIjOhY=X1g(B8EZLEN&9!QTU&@|SStH{iH_FYWshyBjG+7bC41%0 zFa?ie`ybN6a1A?BOL@@DRe6^e(EN}}^TJH|&^s+c_4vqcRbCGbeX3N;+X?woRc-Cm zPhYD#zUVtzK3{d~G+lo-Sv7nikA4RzqJKkmOPi4KEXtd|rmhnlQec4 zy;=S{o3357q%O@UZ>lJ(rSCJ5k1~2VW6wvt7{0MHCp7(*@p*mkob62&*-K4ND*G$% z$M7Yu+KK4NcN%=PDfzY{ilzMZ(yZIl%T#>odmS0?KvL-sG(e71-!^*z_WoiKs delta 1362 zcmX9;dr(wm96fvY-n);xdl!&hg>V;Iz(qh86ek-K90eqcB9bs4<3#G1f{BVlri)+= zngOsC2zp5&nrAEW^TmqXIG08SB7+I1jN2PxAB^w|q(Ebn8VKx&){^xur|#`2dKSAeek- z?b_0FRY2%jZJAn5#^Y)7+fF52;`l#D1F8Aqn@TR= z+AYrM?IzJ4vHIdKBp5HQ%V{F9Y%#DWlg>;Rf9_G$x_8PQ>dUqTA9^K*HC6+nLJ?oHrkOQ59iDfS0@+D>;bH8^5n}bDxQ^ht=>x2U&(=^skC94eDfq( zrgY1<9d}9QQ(Z`75!D^g4Q`u8J7?5T8=2x9DKx~YY~0)pd|w_dP$5?#9bvFsnU zQ+H+%T^Jzg?tBr$K7)0=mCTQc(I?-(4TN;|k_gnIDXXs}e?Bo%uU$%Y_`#JRu z)g_FBKCtEz5HTT$_KW%hD~|#F`UcTeZO9w36c{waP&}jt6A^yiu)cufOMPhAFqDS4 ziwzAehXH+h5EHr#t*8Bf++#R7=@Yv8*wAiGpmC9gj-8CNnT@e8&j1E5BF-fqG^RCe zqmT>UTCL49Fx@!)R~9Qo{EXds_KeOJ9;!DzzW- zT$!lsk-x?)SoSMt_YGmgdgW56<0SyHt<^?ge&RZ+mBHdb3$KN-( zjMGi`5;%AB4s%!~`A5m#gQCq7ImWzdoRiPI(0uv3r!ws3?z?o|m~H;&m3;1QyZPVE zqnX~Q;@%ieVYV7wXr;`mAUf8n!`|`H#+_<@R2$td4dTEeb7>ySmrhpg$zqmGYQzYTMoUom-^nfv>)RuS2Hb7&P?H}$4veL p7HRnro;NSI2GJOWcfCC+eS8n3YoR`WzdDOAKEW;e>XZHE{{da(bx8mK diff --git a/src/apeiron/i18n/apeiron_zh_CN.ts b/src/apeiron/i18n/apeiron_zh_CN.ts index d8f41ff..e337f4f 100644 --- a/src/apeiron/i18n/apeiron_zh_CN.ts +++ b/src/apeiron/i18n/apeiron_zh_CN.ts @@ -229,100 +229,106 @@ ContentPage - - + + + Import modpack 导入整合包 - + + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + 安装 Forge 和 NeoForge 会执行下载的 Java 代码。仅在信任该加载器时继续。 + + + Modpacks (*.mrpack *.zip) 整合包 (*.mrpack *.zip) - - + + All files (*) 所有文件 (*) - - + + Add file 添加文件 - + Mods and packs (*.jar *.zip) 模组与整合包 (*.jar *.zip) - + Content 内容 - + Mods, resource packs, shader packs and data packs for the selected instance. 管理所选实例的模组、资源包、光影包与数据包。 - + Select an instance 选择实例 - + Choose an instance in the library to manage its content. 在游戏库中选择实例以管理其内容。 - + Mods 模组 - + Resource packs 资源包 - + Shaders 光影 - + Data packs 数据包 - + Nothing installed in this category. 此分类下尚未安装任何内容。 - + Remove 移除 - + Mod loader 模组加载器 - + Current: %1 当前:%1 - + Forge and NeoForge installs run the vendor's official installer after verifying its published checksum. 安装 Forge 与 NeoForge 时会先校验官方公布的校验值,再运行厂商提供的官方安装器。 - + Open instance folder 打开实例目录 @@ -523,8 +529,8 @@ - - + + New instance 新建实例 @@ -579,90 +585,100 @@ 详情 - + Memory (MB) 内存(MB) - + JVM arguments JVM 参数 - + Resolution 分辨率 - + Save changes 保存修改 - + Install / verify files 安装 / 校验文件 - + Duplicate 复制 - + Backup worlds 备份存档 - + Export archive 导出压缩包 - + Delete instance 删除实例 - + Delete instance? 删除实例? - + The instance directory and everything inside it will be removed. This cannot be undone. 实例目录及其中的所有内容都将被删除,且无法恢复。 - + Name 名称 - + My world 我的世界 - + Minecraft version Minecraft 版本 - + Mod loader 模组加载器 - + Vanilla 原版 - + Loader versions are fetched from the projects' official metadata. 加载器版本来自各项目的官方元数据。 + + + Run loader installer? + 运行加载器安装程序? + + + + Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader. + 安装 Forge 和 NeoForge 会执行下载的 Java 代码。仅在信任该加载器时继续。 + LogsPage diff --git a/src/apeiron/instances/manager.py b/src/apeiron/instances/manager.py index 185c280..34f084b 100644 --- a/src/apeiron/instances/manager.py +++ b/src/apeiron/instances/manager.py @@ -15,6 +15,7 @@ import builtins import json import shutil +import tempfile import zipfile from dataclasses import dataclass from datetime import datetime @@ -246,9 +247,19 @@ def restore_backup(self, instance_id: str, backup: Path) -> int: # The previous worlds are moved next to (not inside) `saves`, so the # archive can be extracted into a clean directory. existing = directory / f"_pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - if saves.exists(): - shutil.move(str(saves), str(existing)) - written = extract_zip(backup, saves) + with tempfile.TemporaryDirectory(prefix="restore-", dir=directory) as staging: + staged = Path(staging) / "saves" + written = extract_zip(backup, staged) + moved = saves.exists() + if moved: + existing = directory / f"_pre-restore-{new_id()}" + saves.replace(existing) + try: + staged.replace(saves) + except BaseException: + if moved: + existing.replace(saves) + raise log.info("restored %d files from %s", len(written), backup.name) return len(written) @@ -359,6 +370,11 @@ def instance_summary(instance: Instance, size: int | None = None) -> dict[str, A "loaderVersion": instance.loader_version, "loaderLabel": instance.display_loader, "memoryMb": instance.memory_mb, + "jvmArgs": instance.jvm_args, + "gameArgs": instance.game_args, + "width": instance.width, + "height": instance.height, + "directory": instance.directory, "accountId": instance.account_id or "", "lastPlayed": instance.last_played_at or "", "playCount": instance.play_count, diff --git a/src/apeiron/loaders/forge.py b/src/apeiron/loaders/forge.py index 03091bb..09e6958 100644 --- a/src/apeiron/loaders/forge.py +++ b/src/apeiron/loaders/forge.py @@ -33,6 +33,7 @@ from apeiron.core.paths import AppPaths from apeiron.loaders.base import LoaderInstallResult, LoaderVersion, write_version_json from apeiron.mc.manifest import VersionManifest +from apeiron.net.downloader import DownloadManager, DownloadSpec log = get_logger("forge") @@ -101,7 +102,7 @@ def install( with tempfile.TemporaryDirectory(prefix=f"apeiron-{self.name}-") as tmp: work = Path(tmp) installer = work / "installer.jar" - self._download(url, installer) + self._download(url, installer, checksum) actual = _sha1(installer) if actual.lower() != checksum.lower(): raise LoaderError( @@ -143,13 +144,10 @@ def _fetch_checksum(self, url: str) -> str: candidate = text.split()[0] if text else "" return candidate.lower() if len(candidate) == 40 else "" - def _download(self, url: str, target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - tmp = target.with_suffix(".part") - with tmp.open("wb") as handle: - for chunk in self.http.stream(url): - handle.write(chunk) - tmp.replace(target) + def _download(self, url: str, target: Path, checksum: str = "") -> None: + DownloadManager(self.http).download( + DownloadSpec(url=url, destination=target, sha1=checksum, category="loader") + ) def _seed_vanilla(self, mc_version: str, launcher_dir: Path) -> None: """Installers require the vanilla version JSON to be present.""" @@ -304,7 +302,7 @@ def versions(self, mc_version: str) -> list[LoaderVersion]: all_versions = [node.text.strip() for node in root.iter("version") if node.text] # NeoForge 20.4.x targets MC 1.20.4, 21.1.x targets 1.21.1, ... parts = mc_version.split(".") - prefix = f"{parts[1]}.{parts[2]}" if len(parts) >= 3 else "" + prefix = f"{parts[1]}.{parts[2] if len(parts) >= 3 else '0'}" if len(parts) >= 2 else "" matching = [v for v in all_versions if prefix and v.startswith(prefix + ".")] return [ LoaderVersion(loader=self.name, version=version, mc_version=mc_version) diff --git a/src/apeiron/mc/installer.py b/src/apeiron/mc/installer.py index 9547892..ff87196 100644 --- a/src/apeiron/mc/installer.py +++ b/src/apeiron/mc/installer.py @@ -15,6 +15,7 @@ from dataclasses import dataclass, field from pathlib import Path +from apeiron.core.archive import _validate_member from apeiron.core.errors import ArchiveError, ConfigError, PermissionError_ from apeiron.core.logging import get_logger from apeiron.core.paths import AppPaths, current_platform_key @@ -264,7 +265,9 @@ def _install_assets(self, descriptor: VersionDescriptor, report: StageCallback) seen: set[str] = set() for _name, entry in (payload.get("objects") or {}).items(): digest = str(entry.get("hash") or "") - if not digest or digest in seen: + if len(digest) != 40 or any(c not in "0123456789abcdef" for c in digest): + raise ConfigError("asset index contains an invalid object hash") + if digest in seen: continue seen.add(digest) specs.append( @@ -291,6 +294,31 @@ def _install_assets(self, descriptor: VersionDescriptor, report: StageCallback) ) ) + for name, entry in (payload.get("objects") or {}).items(): + digest = str(entry.get("hash") or "") + source = objects_root / digest[:2] / digest + if not source.is_file(): + continue + roots = [] + if payload.get("virtual"): + roots.append(self.paths.assets / "virtual" / descriptor.assets) + if payload.get("map_to_resources"): + roots.append(self.paths.assets / "resources" / descriptor.assets) + for root in roots: + target = _validate_member(name, root) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + def materialize_resources(self, installed: InstalledVersion, instance_dir: Path) -> None: + source = self.paths.assets / "resources" / installed.asset_index + if source.is_dir(): + for path in source.rglob("*"): + if path.is_file(): + relative = path.relative_to(source).as_posix() + target = _validate_member(relative, instance_dir / "resources") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + def _install_logging_config(self, descriptor: VersionDescriptor) -> None: artifact = descriptor.logging_config() if artifact is None: diff --git a/src/apeiron/mc/launch.py b/src/apeiron/mc/launch.py index 00b5833..7a24592 100644 --- a/src/apeiron/mc/launch.py +++ b/src/apeiron/mc/launch.py @@ -81,7 +81,11 @@ def _split_extra(value: str) -> list[str]: import shlex try: - return shlex.split(value, posix=True) + lexer = shlex.shlex(value, posix=True) + lexer.whitespace_split = True + lexer.commenters = "" + lexer.escape = "" + return list(lexer) except ValueError: return value.split() @@ -115,7 +119,7 @@ def build_command(installed: InstalledVersion, options: LaunchOptions) -> list[s "library_directory": str(installed.libraries_root), "resolution_width": str(options.width), "resolution_height": str(options.height), - "game_assets": str(installed.assets_root / "virtual" / "legacy"), + "game_assets": str(installed.assets_root / "virtual" / installed.asset_index), "quickPlayPath": "", "quickPlaySingleplayer": options.quick_play_singleplayer, "quickPlayMultiplayer": options.quick_play_multiplayer, diff --git a/src/apeiron/mc/manifest.py b/src/apeiron/mc/manifest.py index fa58902..a1de123 100644 --- a/src/apeiron/mc/manifest.py +++ b/src/apeiron/mc/manifest.py @@ -98,7 +98,13 @@ def find(self, version_id: str) -> VersionSummary: # -- version JSON ------------------------------------------------------- def fetch_version_json(self, version_id: str) -> dict[str, Any]: summary = self.find(version_id) - payload = self.http.get_json(summary.url) + body = self.http.get_bytes(summary.url, expected_sha1=summary.sha1) + if summary.sha1 and hashlib.sha1(body).hexdigest() != summary.sha1.lower(): + raise IntegrityError(f"version JSON checksum mismatch: {version_id}") + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise NetworkError(f"invalid version JSON: {version_id}") from exc if not isinstance(payload, dict): raise NetworkError(f"version JSON for {version_id} is not an object") if payload.get("id") != version_id: diff --git a/src/apeiron/mc/version.py b/src/apeiron/mc/version.py index 778fdb4..fee4678 100644 --- a/src/apeiron/mc/version.py +++ b/src/apeiron/mc/version.py @@ -48,6 +48,7 @@ def from_dict(cls, raw: Mapping[str, Any]) -> Rule: def _os_matches(rule: Rule, platform_key: str, os_version: str) -> bool: + platform_key = "osx" if platform_key == "macos" else platform_key if rule.os_name is not None and rule.os_name != platform_key: return False if rule.os_arch is not None: @@ -174,7 +175,7 @@ def resolved_artifact(self) -> Artifact | None: return Artifact(path=relative, url=base + relative, sha1=self.sha1, size=self.size) def native_artifact(self, platform_key: str) -> Artifact | None: - classifier = self.natives.get(platform_key) + classifier = self.natives.get("osx" if platform_key == "macos" else platform_key) if not classifier: return None classifier = classifier.replace( @@ -401,7 +402,7 @@ def resolve_arguments( output.append(_substitute(value, values)) elif isinstance(value, Sequence): output.extend(_substitute(str(v), values) for v in value) - return [token for token in output if token != ""] + return output def split_legacy_arguments(legacy: str, values: Mapping[str, str]) -> list[str]: diff --git a/src/apeiron/modpack/curseforge.py b/src/apeiron/modpack/curseforge.py index 7aa0f8d..a7f3aa5 100644 --- a/src/apeiron/modpack/curseforge.py +++ b/src/apeiron/modpack/curseforge.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any -from apeiron.core.archive import is_within +from apeiron.core.archive import extract_zip, is_within from apeiron.core.errors import ModpackError, SecurityError from apeiron.core.http import HttpClient from apeiron.core.logging import get_logger @@ -100,25 +100,7 @@ def read_manifest(archive: Path) -> CurseForgeManifest: def extract_overrides(archive: Path, manifest: CurseForgeManifest, instance_dir: Path) -> int: - written = 0 - root_name = manifest.overrides.strip("/") - with zipfile.ZipFile(archive) as zf: - for info in zf.infolist(): - if info.is_dir(): - continue - if not info.filename.startswith(root_name + "/"): - continue - relative = info.filename[len(root_name) + 1 :] - if not relative: - continue - target = (instance_dir / relative).resolve() - if not is_within(target, instance_dir.resolve()): - raise SecurityError(f"override path escapes the instance: {info.filename}") - target.parent.mkdir(parents=True, exist_ok=True) - with zf.open(info) as src, target.open("wb") as dst: - dst.write(src.read()) - written += 1 - return written + return len(extract_zip(archive, instance_dir, prefixes=(manifest.overrides,))) class CurseForgeClient: @@ -137,7 +119,8 @@ def _headers(self) -> dict[str, str]: raise ModpackError( "CurseForge downloads require an API key", hint=( - "Add your own CurseForge API key in Settings. Apeiron does not ship a key " + "Set the APEIRON_CURSEFORGE_API_KEY environment variable. " + "Apeiron does not ship a key " "and does not download CurseForge files from unofficial mirrors." ), ) diff --git a/src/apeiron/modpack/mrpack.py b/src/apeiron/modpack/mrpack.py index 44ff280..a3e2343 100644 --- a/src/apeiron/modpack/mrpack.py +++ b/src/apeiron/modpack/mrpack.py @@ -19,7 +19,7 @@ from pathlib import Path, PurePosixPath from typing import Any -from apeiron.core.archive import is_within +from apeiron.core.archive import extract_zip, is_within, safe_source_file from apeiron.core.errors import ModpackError, SecurityError from apeiron.core.logging import get_logger from apeiron.net.downloader import DownloadManager, DownloadSpec @@ -50,6 +50,16 @@ def from_dict(cls, raw: dict[str, Any]) -> MrpackFile: raise ModpackError("a modpack file entry has no path") if not downloads: raise ModpackError(f"modpack file {path} has no download URL") + for algorithm, length in (("sha1", 40), ("sha512", 128)): + digest = hashes.get(algorithm) + if digest and ( + not isinstance(digest, str) + or len(digest) != length + or any(c not in "0123456789abcdefABCDEF" for c in digest) + ): + raise ModpackError(f"invalid {algorithm} digest for {path}") + if not hashes.get("sha1") and not hashes.get("sha512"): + raise ModpackError(f"modpack file {path} declares no checksum") env = raw.get("env") or {} side = str(env.get("client", "required")) return cls( @@ -63,7 +73,7 @@ def from_dict(cls, raw: dict[str, Any]) -> MrpackFile: def is_safe_path(self) -> bool: pure = PurePosixPath(self.path) - if pure.is_absolute() or ".." in pure.parts: + if pure.is_absolute() or ".." in pure.parts or "\\" in self.path or ":" in self.path: return False # Modrinth paths are relative to the instance root. return bool(pure.parts) @@ -148,26 +158,7 @@ def read_mrpack(archive: Path) -> MrpackContents: def extract_overrides(archive: Path, instance_dir: Path) -> int: """Copy override trees into the instance, rejecting path traversal.""" - written = 0 - instance_root = instance_dir.resolve() - with zipfile.ZipFile(archive) as zf: - for info in zf.infolist(): - if info.is_dir(): - continue - parts = PurePosixPath(info.filename).parts - if not parts or parts[0] not in OVERRIDE_DIRS: - continue - relative = PurePosixPath(*parts[1:]) - if ".." in relative.parts or relative.is_absolute(): - raise SecurityError(f"override path escapes the instance: {info.filename}") - target = (instance_dir / relative).resolve() - if not is_within(target, instance_root): - raise SecurityError(f"override path escapes the instance: {info.filename}") - target.parent.mkdir(parents=True, exist_ok=True) - with zf.open(info) as src, target.open("wb") as dst: - dst.write(src.read()) - written += 1 - return written + return len(extract_zip(archive, instance_dir, prefixes=OVERRIDE_DIRS)) def download_specs(index: MrpackIndex, instance_dir: Path) -> list[DownloadSpec]: @@ -184,6 +175,8 @@ def download_specs(index: MrpackIndex, instance_dir: Path) -> list[DownloadSpec] url=entry.downloads[0], destination=destination, sha1=entry.sha1, + sha512=entry.sha512, + fallback_urls=tuple(entry.downloads[1:]), size=entry.size, category="modpack", ) @@ -214,6 +207,7 @@ def export_mrpack( files: Iterable[dict[str, Any]], summary: str = "", overrides_dir: Path | None = None, + overrides_prefix: str = "", ) -> Path: """Write a ``.mrpack`` from already-known file metadata.""" dependencies: dict[str, str] = {"minecraft": mc_version} @@ -237,9 +231,11 @@ def export_mrpack( zf.writestr(INDEX_NAME, json.dumps(index, indent=2, ensure_ascii=False)) if overrides_dir and overrides_dir.exists(): for path in sorted(overrides_dir.rglob("*")): - if path.is_file(): - arcname = PurePosixPath("overrides") / PurePosixPath( - path.relative_to(overrides_dir).as_posix() + if safe_source_file(path, overrides_dir): + arcname = ( + PurePosixPath("overrides") + / overrides_prefix + / PurePosixPath(path.relative_to(overrides_dir).as_posix()) ) zf.write(path, str(arcname)) log.info("exported modpack %s (%d files)", target.name, len(index["files"])) diff --git a/src/apeiron/modpack/service.py b/src/apeiron/modpack/service.py index 589ae88..7433e1c 100644 --- a/src/apeiron/modpack/service.py +++ b/src/apeiron/modpack/service.py @@ -22,6 +22,7 @@ from apeiron.instances.manager import InstanceManager from apeiron.loaders.fabric import FabricLoader, QuiltLoader from apeiron.loaders.forge import ForgeLoader, NeoForgeLoader +from apeiron.mc.manifest import VersionManifest from apeiron.modpack import curseforge, mrpack from apeiron.net.downloader import DownloadManager @@ -80,8 +81,8 @@ def __init__( self.loaders: dict[str, Any] = { "fabric": FabricLoader(http, paths), "quilt": QuiltLoader(http, paths), - "forge": ForgeLoader(http, paths, None, java_path), - "neoforge": NeoForgeLoader(http, paths, None, java_path), + "forge": ForgeLoader(http, paths, VersionManifest(http, paths.meta), java_path), + "neoforge": NeoForgeLoader(http, paths, VersionManifest(http, paths.meta), java_path), } # -- detection ---------------------------------------------------------- @@ -104,13 +105,17 @@ def detect_format(archive: Path) -> str: ) # -- import ------------------------------------------------------------- - def import_archive(self, archive: Path, *, name_override: str = "") -> ImportResult: + def import_archive( + self, archive: Path, *, name_override: str = "", allow_execution: bool = False + ) -> ImportResult: fmt = self.detect_format(archive) if fmt == "mrpack": - return self._import_mrpack(archive, name_override) - return self._import_curseforge(archive, name_override) + return self._import_mrpack(archive, name_override, allow_execution) + return self._import_curseforge(archive, name_override, allow_execution) - def _import_mrpack(self, archive: Path, name_override: str) -> ImportResult: + def _import_mrpack( + self, archive: Path, name_override: str, allow_execution: bool = False + ) -> ImportResult: contents = mrpack.read_mrpack(archive) index = contents.index instance = self.instances.create( @@ -122,34 +127,54 @@ def _import_mrpack(self, archive: Path, name_override: str) -> ImportResult: "modpack": {"name": index.name, "versionId": index.version_id, "format": "mrpack"} }, ) - instance_dir = Path(instance.directory) - loader_ok = False - if index.loader != "vanilla" and index.loader_version: - loader_ok = self._install_loader(instance, index.loader, index.loader_version) - files = mrpack.install_files(contents, instance_dir, self.downloader) - for entry in index.files: - self._record_file( - instance, - instance_dir, - entry.path, - "mod", - entry.sha1, - entry.size, - entry.downloads[0], - ) - overrides = mrpack.extract_overrides(archive, instance_dir) - instance.installed = loader_ok or index.loader == "vanilla" - self.instances.update(instance) - log.info("imported mrpack %s (%d files, %d overrides)", instance.name, files, overrides) - return ImportResult(instance, "mrpack", files, overrides, loader_ok) + try: + instance_dir = Path(instance.directory) + loader_ok = False + if index.loader != "vanilla" and index.loader_version: + loader_ok = self._install_loader( + instance, index.loader, index.loader_version, allow_execution=allow_execution + ) + files = mrpack.install_files(contents, instance_dir, self.downloader) + for entry in index.files: + kind = next( + ( + k + for k, directory in CONTENT_DIRS.items() + if entry.path.startswith(directory + "/") + ), + None, + ) + if kind is None or entry.side == "server": + continue + self._record_file( + instance, + instance_dir, + entry.path, + kind, + entry.sha1, + entry.size, + entry.downloads[0], + sha512=entry.sha512, + ) + overrides = mrpack.extract_overrides(archive, instance_dir) + instance.installed = loader_ok or index.loader == "vanilla" + self.instances.update(instance) + log.info("imported mrpack %s (%d files, %d overrides)", instance.name, files, overrides) + return ImportResult(instance, "mrpack", files, overrides, loader_ok) + except BaseException: + self.instances.delete(instance.id) + raise - def _import_curseforge(self, archive: Path, name_override: str) -> ImportResult: + def _import_curseforge( + self, archive: Path, name_override: str, allow_execution: bool = False + ) -> ImportResult: manifest = curseforge.read_manifest(archive) if not self.curseforge.enabled: raise ModpackError( "this CurseForge pack cannot be installed without an API key", hint=( - "Add a CurseForge API key in Settings. Mod files are distributed by " + "Set the APEIRON_CURSEFORGE_API_KEY environment variable. " + "Mod files are distributed by " "CurseForge and may not be fetched from unofficial mirrors." ), ) @@ -166,33 +191,54 @@ def _import_curseforge(self, archive: Path, name_override: str) -> ImportResult: } }, ) - instance_dir = Path(instance.directory) - loader_ok = False - if manifest.loader != "vanilla" and manifest.loader_version: - loader_ok = self._install_loader(instance, manifest.loader, manifest.loader_version) - files = self.curseforge.install_files(manifest, instance_dir, self.downloader) - overrides = curseforge.extract_overrides(archive, manifest, instance_dir) - instance.installed = loader_ok or manifest.loader == "vanilla" - self.instances.update(instance) - return ImportResult(instance, "curseforge", files, overrides, loader_ok) + try: + instance_dir = Path(instance.directory) + loader_ok = False + if manifest.loader != "vanilla" and manifest.loader_version: + loader_ok = self._install_loader( + instance, + manifest.loader, + manifest.loader_version, + allow_execution=allow_execution, + ) + specs = self.curseforge.resolve_specs(manifest, instance_dir) + if specs: + self.downloader.run(specs) + files = len(specs) + for spec in specs: + self._record_file( + instance, + instance_dir, + spec.destination.relative_to(instance_dir).as_posix(), + "mod", + spec.sha1, + spec.size, + spec.url, + source="curseforge", + ) + overrides = curseforge.extract_overrides(archive, manifest, instance_dir) + instance.installed = loader_ok or manifest.loader == "vanilla" + self.instances.update(instance) + return ImportResult(instance, "curseforge", files, overrides, loader_ok) + except BaseException: + self.instances.delete(instance.id) + raise - def _install_loader(self, instance: Instance, loader: str, version: str) -> bool: + def _install_loader( + self, instance: Instance, loader: str, version: str, *, allow_execution: bool = False + ) -> bool: provider = self.loaders.get(loader) if provider is None or not version: - return False - try: - if loader in ("forge", "neoforge"): - result = provider.install( - instance.mc_version, - version, - java_path=self.java_path, - allow_execution=True, - ) - else: - result = provider.install(instance.mc_version, version) - except Exception as exc: - log.error("loader install failed for %s %s: %s", loader, version, exc) - return False + raise ModpackError(f"unsupported loader or missing version: {loader}") + if loader in ("forge", "neoforge"): + result = provider.install( + instance.mc_version, + version, + java_path=self.java_path, + allow_execution=allow_execution, + ) + else: + result = provider.install(instance.mc_version, version) instance.loader = cast(Any, loader) instance.loader_version = version instance.metadata = {**instance.metadata, "loaderVersionId": result.version_id} @@ -208,15 +254,18 @@ def _record_file( sha1: str, size: int, url: str = "", + sha512: str = "", + source: str = "modrinth", ) -> None: path = instance_dir / relative if not path.exists(): return record = ModRecord( instance_id=instance.id, - filename=path.name, + filename=path.relative_to(instance_dir / CONTENT_DIRS[kind]).as_posix(), kind=kind, - source="modrinth", + source=source, + sha512=sha512, sha1=sha1 or _sha1(path), size=size or path.stat().st_size, source_url=url, @@ -305,6 +354,7 @@ def export_mrpack(self, instance: Instance, target: Path) -> Path: files=files, summary="Exported by Apeiron", overrides_dir=overrides if overrides.exists() else None, + overrides_prefix="config", ) diff --git a/src/apeiron/net/downloader.py b/src/apeiron/net/downloader.py index 16a2fd7..a49fabd 100644 --- a/src/apeiron/net/downloader.py +++ b/src/apeiron/net/downloader.py @@ -25,7 +25,7 @@ import time from collections.abc import Callable, Iterable, Sequence from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from apeiron.core.errors import ( @@ -57,6 +57,8 @@ class DownloadSpec: size: int = 0 category: str = "other" task_id: str = "" + sha512: str = "" + fallback_urls: tuple[str, ...] = () @property def key(self) -> str: @@ -139,6 +141,7 @@ def __init__( self.repo = repo self.progress_callback = progress_callback self.chunk_size = chunk_size + self._batch_lock = threading.Lock() self._cancel = threading.Event() self._lock = threading.Lock() self._progress = Progress() @@ -161,6 +164,11 @@ def reset_cancel(self) -> None: self._progress.cancelled = False def run(self, specs: Sequence[DownloadSpec]) -> list[DownloadResult]: + # Serialize batches: cancellation and progress belong to the active batch. + with self._batch_lock: + return self._run_batch(specs) + + def _run_batch(self, specs: Sequence[DownloadSpec]) -> list[DownloadResult]: """Download every spec, returning successes; failures are raised. The first failure cancels the remaining work and raises, so callers @@ -199,6 +207,8 @@ def run(self, specs: Sequence[DownloadSpec]) -> list[DownloadResult]: failures.append( DownloadFailure(spec, str(exc) or "cancelled", error_code=exc.code) ) + with self._lock: + self._progress.failed_tasks += 1 self.cancel() except ( DownloadError, @@ -222,6 +232,9 @@ def run(self, specs: Sequence[DownloadSpec]) -> list[DownloadResult]: self.cancel() finally: executor.shutdown(wait=True, cancel_futures=True) + with self._lock: + self._progress.failed_tasks = len(failures) + self._emit() if failures: first = failures[0] @@ -260,13 +273,29 @@ def _download_one(self, spec: DownloadSpec) -> DownloadResult: for attempt in range(1, self.retries + 2): try: - written = self._stream_to_part(spec, part) - self._verify(part, spec) + urls: list[str] = [] + for original in (spec.url, *spec.fallback_urls): + candidates = ( + self.http.candidates(original) + if hasattr(self.http, "candidates") + else (original,) + ) + urls.extend(u for u in candidates if u not in urls) + for position, url in enumerate(urls): + attempt_spec = replace(spec, url=url) + try: + written = self._stream_to_part(attempt_spec, part) + self._verify(part, spec) + break + except (NetworkError, IntegrityError, OSError): + self._rollback_bytes(part) + part.unlink(missing_ok=True) + if position == len(urls) - 1: + raise self._commit(part, destination) elapsed = time.monotonic() - started with self._lock: self._progress.completed_tasks += 1 - self._progress.bytes_done += spec.size or written self._emit() self._task_finish(task, "completed", written) log.info("downloaded %s (%d bytes, %.2fs)", destination.name, written, elapsed) @@ -274,6 +303,7 @@ def _download_one(self, spec: DownloadSpec) -> DownloadResult: spec=spec, path=destination, bytes_written=written, seconds=elapsed ) except CancelledError: + self._rollback_bytes(part) part.unlink(missing_ok=True) self._task_finish(task, "cancelled", 0) raise @@ -315,10 +345,15 @@ def _download_one(self, spec: DownloadSpec) -> DownloadResult: raise DownloadError(f"failed to download {destination.name}", detail=str(last_error)) + def _rollback_bytes(self, part: Path) -> None: + size = part.stat().st_size if part.exists() else 0 + with self._lock: + self._progress.bytes_done = max(0, self._progress.bytes_done - size) + self._update_speed() + def _stream_to_part(self, spec: DownloadSpec, part: Path) -> int: part.parent.mkdir(parents=True, exist_ok=True) written = 0 - hasher = hashlib.sha1() with self._lock: self._progress.current_file = spec.destination.name self._emit() @@ -328,7 +363,6 @@ def _stream_to_part(self, spec: DownloadSpec, part: Path) -> int: if self._cancel.is_set(): raise CancelledError("download cancelled") handle.write(chunk) - hasher.update(chunk) written += len(chunk) with self._lock: self._progress.bytes_done += len(chunk) @@ -342,7 +376,6 @@ def _stream_to_part(self, spec: DownloadSpec, part: Path) -> int: if exc.errno == errno.ENOSPC: raise DiskSpaceError(f"no space left on device for {part.parent}") from exc raise - self._stream_hasher = hasher # type: ignore[attr-defined] return written def _verify(self, part: Path, spec: DownloadSpec) -> None: @@ -351,17 +384,17 @@ def _verify(self, part: Path, spec: DownloadSpec) -> None: f"size mismatch for {spec.destination.name}", detail=f"expected {spec.size}, got {part.stat().st_size}", ) - if spec.sha1: - digest = hashlib.sha1() - with part.open("rb") as handle: - for block in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(block) - actual = digest.hexdigest() - if actual.lower() != spec.sha1.lower(): - raise IntegrityError( - f"checksum mismatch for {spec.destination.name}", - detail=f"expected {spec.sha1}, got {actual}", - ) + for algorithm in ("sha1", "sha512"): + expected = getattr(spec, algorithm) + if expected: + digest = hashlib.new(algorithm) + with part.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest().lower() != expected.lower(): + raise IntegrityError( + f"{algorithm} checksum mismatch for {spec.destination.name}" + ) @staticmethod def _commit(part: Path, destination: Path) -> None: @@ -383,12 +416,12 @@ def _is_complete(self, destination: Path, spec: DownloadSpec) -> bool: size = destination.stat().st_size if spec.size and size != spec.size: return False - if spec.sha1: - digest = hashlib.sha1() - with destination.open("rb") as handle: - for block in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest().lower() == spec.sha1.lower() + if spec.sha1 or spec.sha512: + try: + self._verify(destination, spec) + except IntegrityError: + return False + return True return bool(spec.size) def _validate_destination(self, destination: Path, size: int) -> None: diff --git a/src/apeiron/net/sources.py b/src/apeiron/net/sources.py new file mode 100644 index 0000000..f97f7ce --- /dev/null +++ b/src/apeiron/net/sources.py @@ -0,0 +1,50 @@ +"""BMCLAPI-compatible URL routing, limited to public Minecraft downloads. + +Never rewrite authentication, CurseForge, Modrinth, or arbitrary pack URLs. +The official URL remains the last candidate; hashes always describe the +original bytes and are checked independently of the selected mirror. +""" + +from __future__ import annotations + +from urllib.parse import urlsplit, urlunsplit + +MIRROR_ROOTS = ( + "https://bsmirror.hkmc.online", + "https://mirror.hkmc.online", + "https://bmclapi2.bangbang93.com", +) +MOJANG_HOSTS = frozenset( + { + "launchermeta.mojang.com", + "launcher.mojang.com", + "piston-meta.mojang.com", + "piston-data.mojang.com", + } +) + + +def download_candidates(url: str) -> tuple[str, ...]: + parsed = urlsplit(url) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.port: + return (url,) + host = parsed.hostname + path = parsed.path + if host in MOJANG_HOSTS: + relative = path + elif host == "resources.download.minecraft.net": + relative = "/assets" + path + elif host == "libraries.minecraft.net": + relative = "/maven" + path + elif host in {"maven.minecraftforge.net", "files.minecraftforge.net"}: + relative = "/maven" + path.removeprefix("/maven") + elif host == "maven.neoforged.net" and path.startswith("/releases/net/neoforged/"): + relative = "/maven" + path.removeprefix("/releases") + elif host == "meta.fabricmc.net": + relative = "/fabric-meta" + path + elif host == "maven.fabricmc.net": + relative = "/maven" + path + else: + return (url,) + suffix = urlunsplit(("", "", relative, parsed.query, "")) + return (*(root + suffix for root in MIRROR_ROOTS), url) diff --git a/src/apeiron/services/app_context.py b/src/apeiron/services/app_context.py index 5818137..18e8599 100644 --- a/src/apeiron/services/app_context.py +++ b/src/apeiron/services/app_context.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import platform import sys from collections.abc import Callable @@ -120,6 +121,7 @@ def build_context( http = HttpClient( HttpConfig( + use_mirrors=True, timeout=settings.download_timeout, retries=settings.download_retries, ) @@ -161,7 +163,7 @@ def build_context( downloader, http=http, paths=paths, - curseforge_key="", + curseforge_key=os.environ.get("APEIRON_CURSEFORGE_API_KEY", ""), java_path=settings_store.current.java_path, ) diff --git a/src/apeiron/services/launcher.py b/src/apeiron/services/launcher.py index 190d1b3..bbb8dda 100644 --- a/src/apeiron/services/launcher.py +++ b/src/apeiron/services/launcher.py @@ -122,6 +122,7 @@ def launch( access_token: str = "0", user_type: str = "legacy", xuid: str = "", + client_id: str = "", on_line: Callable[[str], None] | None = None, on_exit: Callable[[int], None] | None = None, on_stage: Callable[[InstallStage], None] | None = None, @@ -137,6 +138,8 @@ def launch( if not instance_dir.exists(): raise LaunchError(f"the instance directory is missing: {instance_dir}") + self.installer.materialize_resources(installed, instance_dir) + options = LaunchOptions( instance_name=instance.name, game_directory=instance_dir, @@ -146,6 +149,7 @@ def launch( access_token=access_token, user_type=user_type, xuid=xuid, + client_id=client_id, memory_mb=instance.memory_mb, width=instance.width, height=instance.height, @@ -164,8 +168,10 @@ def launch( started_at=time.time(), ) + started_at = self.state.started_at + def _exit(code: int) -> None: - duration = int(time.time() - (self.state.started_at or time.time())) + duration = max(0, int(time.time() - started_at)) status = "exited" if code == 0 else "crashed" self.launches.finish(record.id, code, status, duration) self.instances_repo.mark_played(instance.id, duration) @@ -182,24 +188,35 @@ def _exit(code: int) -> None: on_line=on_line, on_exit=_exit, ) - process.start() + try: + process.start() + except Exception: + self.launches.finish(record.id, -1, "failed", 0) + self.state = LaunchState(instance_id=instance.id, phase="exited", exit_code=-1) + raise self.process = process - self.state = LaunchState( - instance_id=instance.id, - phase="running", - detail=str(runtime.path), - pids=process.pid or 0, - started_at=time.time(), - ) + if process.running: + self.state = LaunchState( + instance_id=instance.id, + phase="running", + detail=str(runtime.path), + pids=process.pid or 0, + started_at=started_at, + ) return process def stop(self) -> int | None: if self.process is None: return None code = self.process.stop() - self.state = LaunchState(instance_id=self.state.instance_id, phase="exited", exit_code=code) return code + def delete_instance(self, instance_id: str, delete: Callable[[str], None]) -> None: + with self._lock: + if self.state.instance_id == instance_id and self.running: + raise LaunchError("cannot delete an instance while its game is running") + delete(instance_id) + @property def running(self) -> bool: return self.process is not None and self.process.running diff --git a/src/apeiron/ui/app.py b/src/apeiron/ui/app.py index d73e7af..fcbba4a 100644 --- a/src/apeiron/ui/app.py +++ b/src/apeiron/ui/app.py @@ -63,6 +63,14 @@ def _configure_font(app: QGuiApplication) -> None: app.setFont(font) +def configure_platform_fonts() -> None: + """Give offscreen Qt access to fonts installed with Windows.""" + if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" + if fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(fonts) + + def create_engine( context: AppContext, *, qml_dir: Path | None = None ) -> tuple[QQmlApplicationEngine, Backend]: @@ -108,6 +116,7 @@ def run(argv: Sequence[str] | None = None) -> int: QCoreApplication.setAttribute("Qt.AA_UseHighDpiPixmaps", True) if hasattr( QCoreApplication, "AA_UseHighDpiPixmaps" ) else None + configure_platform_fonts() app = QGuiApplication(argv) app.setApplicationName("Apeiron") app.setApplicationDisplayName("Apeiron") @@ -139,7 +148,8 @@ def _shutdown(context: AppContext, backend: Backend | None = None) -> None: # before the engine and its objects are destroyed. backend.shutdown() try: - context.launcher.stop() + if context.settings.close_game_on_launcher_exit: + context.launcher.stop() except Exception: pass try: diff --git a/src/apeiron/ui/backend.py b/src/apeiron/ui/backend.py index e1d317f..331ef8e 100644 --- a/src/apeiron/ui/backend.py +++ b/src/apeiron/ui/backend.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any -from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal, Slot +from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, QUrl, Signal, Slot from apeiron.core import __version__ from apeiron.core.errors import ApeironError @@ -82,6 +82,7 @@ def _emit(self, signal, payload) -> None: class Backend(QObject): """The single object exposed to QML as ``App``.""" + contentChanged = Signal() instancesChanged = Signal() accountsChanged = Signal() downloadsChanged = Signal() @@ -314,6 +315,9 @@ def _on_failed(self, task_id: str, error: dict[str, Any]) -> None: self._pending.pop(task_id, None) self._busy = "" self.busyChanged.emit() + if self._status.get("kind") == "download": + self._status = {"kind": "idle", "text": ""} + self.statusChanged.emit() self.notify.emit("error", error.get("code", "error"), self._error_text(error)) def _on_progress(self, task_id: str, payload: Any) -> None: @@ -369,7 +373,12 @@ def _refresh_downloads(self) -> None: def _download_progress(self, progress: Any) -> None: payload = progress.as_dict() if hasattr(progress, "as_dict") else dict(progress) - self._status = {"kind": "download", **payload} + terminal = payload.get("cancelled") or ( + payload.get("totalTasks", 0) > 0 + and payload.get("completedTasks", 0) + payload.get("failedTasks", 0) + >= payload["totalTasks"] + ) + self._status = {"kind": "idle" if terminal else "download", **payload} self.statusChanged.emit() # ------------------------------------------------------------------ @@ -428,8 +437,15 @@ def done(result: dict[str, Any]) -> None: self._run("java", work, done) @Slot(str, str, str, str, str) + @Slot(str, str, str, str, str, bool) def createInstance( - self, name: str, mc_version: str, loader: str, loader_version: str, account_id: str + self, + name: str, + mc_version: str, + loader: str, + loader_version: str, + account_id: str, + allow_execution: bool = False, ) -> None: def work() -> dict[str, Any]: instance = self.ctx.instances.create( @@ -442,16 +458,15 @@ def work() -> dict[str, Any]: width=854, height=480, ) - loader_id = "" - if loader and loader != "vanilla" and loader_version: - provider = self.ctx.modpacks.loaders.get(loader) - if provider is not None: - result = provider.install(mc_version, loader_version) - loader_id = result.version_id - instance.loader = loader - instance.loader_version = loader_version - instance.metadata = {**instance.metadata, "loaderVersionId": loader_id} - self.ctx.instances.update(instance) + try: + if loader and loader != "vanilla": + self.ctx.modpacks.java_path = self.ctx.settings.java_path + self.ctx.modpacks._install_loader( + instance, loader, loader_version, allow_execution=allow_execution + ) + except BaseException: + self.ctx.instances.delete(instance.id) + raise return {"id": instance.id, "name": instance.name} def done(result: dict[str, Any]) -> None: @@ -467,7 +482,7 @@ def done(result: dict[str, Any]) -> None: def deleteInstance(self, instance_id: str) -> None: def work() -> dict[str, Any]: instance = self.ctx.instances.get(instance_id) - self.ctx.instances.delete(instance_id) + self.ctx.launcher.delete_instance(instance_id, self.ctx.instances.delete) return {"name": instance.name} def done(result: dict[str, Any]) -> None: @@ -582,6 +597,7 @@ def work() -> dict[str, Any]: access_token=session.access_token, user_type=session.user_type, xuid=session.xuid, + client_id=session.client_id, on_line=self._game_line, on_exit=self._game_exit, on_stage=lambda stage: self._signals.progress.emit( @@ -776,9 +792,25 @@ def setSetting(self, key: str, value: Any) -> None: # content, modpacks, files # ------------------------------------------------------------------ @Slot(str) - def importModpack(self, path: str) -> None: + def importInstance(self, path: str) -> None: def work() -> dict[str, Any]: - result = self.ctx.modpacks.import_archive(Path(path)) + instance = self.ctx.instances.import_instance(local_path(path)) + return {"id": instance.id} + + def done(result: dict[str, Any]) -> None: + self._refresh_instances() + self.selectInstance(result["id"]) + + self._run("import-instance", work, done) + + @Slot(str) + @Slot(str, bool) + def importModpack(self, path: str, allow_execution: bool = False) -> None: + def work() -> dict[str, Any]: + self.ctx.modpacks.java_path = self.ctx.settings.java_path + result = self.ctx.modpacks.import_archive( + local_path(path), allow_execution=allow_execution + ) return result.as_dict() def done(result: dict[str, Any]) -> None: @@ -793,7 +825,7 @@ def done(result: dict[str, Any]) -> None: def exportInstance(self, instance_id: str, target: str) -> None: def work() -> dict[str, Any]: instance = self.ctx.instances.get(instance_id) - path = Path(target) + path = local_path(target) if path.suffix.lower() == ".mrpack": out = self.ctx.modpacks.export_mrpack(instance, path) else: @@ -809,10 +841,11 @@ def done(result: dict[str, Any]) -> None: def installContent(self, instance_id: str, path: str, kind: str) -> None: def work() -> dict[str, Any]: instance = self.ctx.instances.get(instance_id) - record = self.ctx.modpacks.install_content_file(instance, Path(path), kind) + record = self.ctx.modpacks.install_content_file(instance, local_path(path), kind) return {"filename": record.filename} def done(result: dict[str, Any]) -> None: + self.contentChanged.emit() self.notify.emit("success", "content.installed", result["filename"]) self._run("install-content", work, done) @@ -828,7 +861,7 @@ def work() -> dict[str, Any]: self.ctx.modpacks.set_content_enabled(instance, record, enabled) return {} - self._run("toggle-content", work) + self._run("toggle-content", work, lambda result: self.contentChanged.emit()) @Slot(str, str) def removeContent(self, instance_id: str, record_id: str) -> None: @@ -840,7 +873,7 @@ def work() -> dict[str, Any]: self.ctx.modpacks.remove_content(instance, record) return {} - self._run("remove-content", work) + self._run("remove-content", work, lambda result: self.contentChanged.emit()) @Slot(str) def contentFor(self, instance_id: str) -> list[dict[str, Any]]: @@ -970,6 +1003,9 @@ def download(url: str, target: Path, sha256: str) -> None: return runtime.as_dict() def done(result: dict[str, Any]) -> None: + self.ctx.settings_store.update(java_path=result["path"]) + self.ctx.modpacks.java_path = result["path"] + self.settingsChanged.emit() self.detect_java() self.notify.emit("success", "java.installed", str(result.get("version", ""))) @@ -1058,7 +1094,8 @@ def done(result: dict[str, Any]) -> None: def shutdown(self, timeout_ms: int = 5000) -> None: """Stop background work before the engine is destroyed.""" try: - self.ctx.launcher.stop() + if self.ctx.settings.close_game_on_launcher_exit: + self.ctx.launcher.stop() except Exception: pass self._pool.waitForDone(timeout_ms) @@ -1075,3 +1112,12 @@ def _qt_version() -> str: from PySide6.QtCore import qVersion return qVersion() + + +def local_path(value: str) -> Path: + url = QUrl(value) + if url.isLocalFile(): + return Path(url.toLocalFile()) + if url.scheme() and not (len(value) > 1 and value[1] == ":"): + raise ApeironError("a local file path is required") + return Path(value) diff --git a/src/apeiron/ui/qml/pages/ContentPage.qml b/src/apeiron/ui/qml/pages/ContentPage.qml index ceffed5..c757a7e 100644 --- a/src/apeiron/ui/qml/pages/ContentPage.qml +++ b/src/apeiron/ui/qml/pages/ContentPage.qml @@ -7,6 +7,22 @@ import Apeiron.Components 1.0 Item { id: page + property string pendingPack: "" + Dialog { + id: packConsent + implicitWidth: 460 + implicitHeight: 240 + title: qsTr("Import modpack") + modal: true + anchors.centerIn: parent + standardButtons: Dialog.Cancel | Dialog.Ok + Label { + width: 400 + wrapMode: Text.WordWrap + text: qsTr("Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader.") + } + onAccepted: App.importModpack(page.pendingPack, true) + } property var items: [] property string kindFilter: "mod" @@ -18,6 +34,7 @@ Item { target: App function onSelectionChanged() { page.reload() } function onInstancesChanged() { page.reload() } + function onContentChanged() { page.reload() } } Component.onCompleted: reload() @@ -27,7 +44,7 @@ Item { title: qsTr("Import modpack") fileMode: FileDialog.OpenFile nameFilters: [qsTr("Modpacks (*.mrpack *.zip)"), qsTr("All files (*)")] - onAccepted: App.importModpack(selectedFile) + onAccepted: { page.pendingPack = selectedFile.toString(); packConsent.open() } } FileDialog { diff --git a/src/apeiron/ui/qml/pages/LibraryPage.qml b/src/apeiron/ui/qml/pages/LibraryPage.qml index c912f08..26413ae 100644 --- a/src/apeiron/ui/qml/pages/LibraryPage.qml +++ b/src/apeiron/ui/qml/pages/LibraryPage.qml @@ -21,7 +21,7 @@ Item { title: qsTr("Import instance archive") fileMode: FileDialog.OpenFile nameFilters: [qsTr("Instance archive (*.zip)"), qsTr("All files (*)")] - onAccepted: App.importModpack(selectedFile) + onAccepted: App.importInstance(selectedFile) } RowLayout { @@ -184,13 +184,11 @@ Item { textRole: "name" valueRole: "id" onActivated: App.selectAccount(currentValue, App.selectedInstanceId) - Component.onCompleted: { - for (var i = 0; i < App.accounts.length; ++i) { - if (App.accounts[i].id === App.selectedInstance.accountId) { - currentIndex = i - break - } - } + currentIndex: { + var selected = App.selectedInstance.accountId + for (var i = 0; i < App.accounts.length; ++i) + if (App.accounts[i].id === selected) return i + return -1 } } @@ -238,7 +236,7 @@ Item { App.selectedInstance.name, accountBox.currentValue || "", jvmField.text, - "", + App.selectedInstance.gameArgs || "", memoryBox.value, parseInt(widthField.text), parseInt(heightField.text)) @@ -363,11 +361,33 @@ Item { onAccepted: { var loaderName = ["vanilla", "fabric", "quilt", "forge", "neoforge"][loaderBox.currentIndex] var loaderVersion = loaderBox.currentIndex === 0 ? "" : (loaderVersionBox.currentText || "") - App.createInstance(nameField.text, versionBox.currentText, loaderName, loaderVersion, "") + if (loaderName === "forge" || loaderName === "neoforge") { + loaderConsent.open() + } else { + App.createInstance(nameField.text, versionBox.currentText, loaderName, loaderVersion, "") + } } onOpened: { if (App.versions.length > 0 && versionBox.currentIndex < 0) versionBox.currentIndex = 0 } } + Dialog { + id: loaderConsent + implicitWidth: 460 + implicitHeight: 240 + title: qsTr("Run loader installer?") + modal: true + anchors.centerIn: parent + standardButtons: Dialog.Cancel | Dialog.Ok + Label { + width: 400 + wrapMode: Text.WordWrap + text: qsTr("Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader.") + } + onAccepted: App.createInstance(nameField.text, versionBox.currentText, + ["vanilla", "fabric", "quilt", "forge", "neoforge"][loaderBox.currentIndex], + loaderVersionBox.currentText, "", true) + } + } diff --git a/src/apeiron/ui/qml/pages/SettingsPage.qml b/src/apeiron/ui/qml/pages/SettingsPage.qml index 1b2cf71..600ea63 100644 --- a/src/apeiron/ui/qml/pages/SettingsPage.qml +++ b/src/apeiron/ui/qml/pages/SettingsPage.qml @@ -135,7 +135,7 @@ Item { spacing: 8 Button { text: qsTr("Install Java 17"); onClicked: App.installJava(17) } Button { text: qsTr("Install Java 21"); onClicked: App.installJava(21) } - Button { text: qsTr("Re-scan"); flat: true; onClicked: App.detectJava() } + Button { text: qsTr("Re-scan"); flat: true; onClicked: App.detect_java() } } } @@ -158,7 +158,7 @@ Item { checked: App.settings.showSnapshots onToggled: App.setSetting("show_snapshots", checked) } - Button { text: qsTr("Refresh versions"); flat: true; onClicked: App.loadVersions(false) } + Button { text: qsTr("Refresh versions"); flat: true; onClicked: App.load_versions(false) } } } diff --git a/tests/test_modpack.py b/tests/test_modpack.py index 07e1b60..2fd3365 100644 --- a/tests/test_modpack.py +++ b/tests/test_modpack.py @@ -2,9 +2,11 @@ from __future__ import annotations +import hashlib import json import zipfile from pathlib import Path +from types import SimpleNamespace import pytest @@ -54,7 +56,10 @@ def test_read_mrpack(tmp_path: Path): [ { "path": "mods/example.jar", - "hashes": {"sha1": "abc", "sha512": "def"}, + "hashes": { + "sha1": hashlib.sha1(PAYLOAD).hexdigest(), + "sha512": hashlib.sha512(PAYLOAD).hexdigest(), + }, "downloads": ["https://cdn.modrinth.com/example.jar"], "fileSize": 100, } @@ -74,7 +79,7 @@ def test_mrpack_rejects_traversal(tmp_path: Path): [ { "path": "../../escape.jar", - "hashes": {"sha1": "abc"}, + "hashes": {"sha1": hashlib.sha1(PAYLOAD).hexdigest()}, "downloads": ["https://x/y.jar"], "fileSize": 1, } @@ -104,13 +109,13 @@ def test_extract_overrides_only_from_override_tree(tmp_path: Path): assert (target / "mods" / "x.jar").read_bytes() == b"binary" -def test_import_mrpack_creates_instance(tmp_path: Path, repos, app_paths): +def test_import_mrpack_creates_instance(tmp_path: Path, repos, app_paths, monkeypatch): archive = _write_mrpack( tmp_path / "pack.mrpack", [ { "path": "mods/fabric-api.jar", - "hashes": {"sha1": "abc"}, + "hashes": {"sha1": hashlib.sha1(PAYLOAD).hexdigest()}, "downloads": ["https://cdn.modrinth.com/a.jar"], "fileSize": len(PAYLOAD), } @@ -127,6 +132,11 @@ def test_import_mrpack_creates_instance(tmp_path: Path, repos, app_paths): http=None, paths=app_paths, ) + monkeypatch.setattr( + service.loaders["fabric"], + "install", + lambda *args, **kwargs: SimpleNamespace(version_id="fabric-test"), + ) result = service.import_archive(archive) assert result.format == "mrpack" assert result.files_installed == 1 @@ -229,7 +239,10 @@ def test_export_mrpack_is_valid(tmp_path: Path): files=[ { "path": "mods/a.jar", - "hashes": {"sha1": "abc", "sha512": "def"}, + "hashes": { + "sha1": hashlib.sha1(PAYLOAD).hexdigest(), + "sha512": hashlib.sha512(PAYLOAD).hexdigest(), + }, "downloads": ["https://cdn.modrinth.com/a.jar"], "fileSize": 10, "env": {"client": "required", "server": "optional"}, diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py new file mode 100644 index 0000000..aa88e0a --- /dev/null +++ b/tests/test_review_regressions.py @@ -0,0 +1,560 @@ +"""Regression coverage for PR #1 review findings and ordered mirror fallback.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import threading +import time +import urllib.error +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from apeiron.auth.microsoft import MicrosoftAuth +from apeiron.core.archive import extract_zip, zip_directory +from apeiron.core.config import Settings, SettingsStore +from apeiron.core.errors import ( + ArchiveError, + DownloadError, + IntegrityError, + LoaderError, + NetworkError, + PermissionError_, +) +from apeiron.core.http import HttpClient, HttpConfig, Response +from apeiron.core.paths import AppPaths +from apeiron.instances.manager import InstanceManager, instance_summary +from apeiron.mc.launch import _split_extra +from apeiron.mc.manifest import MANIFEST_URL, VersionManifest +from apeiron.mc.version import Library, Rule, rules_allow +from apeiron.modpack import curseforge, mrpack +from apeiron.modpack.service import ModpackService +from apeiron.net.downloader import DownloadManager, DownloadSpec +from apeiron.net.sources import MIRROR_ROOTS, download_candidates + +DATA = b"verified Minecraft bytes" + + +@pytest.mark.parametrize( + "path,prefix", + [ + ("https://piston-meta.mojang.com/v1/x.json", "/v1/x.json"), + ("https://piston-data.mojang.com/v1/x.jar", "/v1/x.jar"), + ("https://resources.download.minecraft.net/ab/abcdef", "/assets/ab/abcdef"), + ("https://libraries.minecraft.net/org/example.jar", "/maven/org/example.jar"), + ("https://maven.minecraftforge.net/net/forge.jar", "/maven/net/forge.jar"), + ("https://meta.fabricmc.net/v2/versions/game", "/fabric-meta/v2/versions/game"), + ( + "https://maven.neoforged.net/releases/net/neoforged/neoforge/x.jar", + "/maven/net/neoforged/neoforge/x.jar", + ), + ], +) +def test_mirror_priority_and_mapping(path, prefix): + assert download_candidates(path) == (*(root + prefix for root in MIRROR_ROOTS), path) + + +@pytest.mark.parametrize( + "url", + [ + "https://login.microsoftonline.com/token", + "https://api.minecraftservices.com/minecraft/profile", + "https://api.curseforge.com/v1/mods/1", + "https://cdn.modrinth.com/a.jar", + "https://libraries.minecraft.net.evil.test/a.jar", + "https://user:secret@libraries.minecraft.net/a.jar", + ], +) +def test_unrelated_and_authenticated_urls_are_never_mirrored(url): + assert download_candidates(url) == (url,) + + +def test_metadata_fallback_rejects_corrupt_hash(): + http = HttpClient(HttpConfig(use_mirrors=True, retries=0)) + url = "https://piston-meta.mojang.com/v1/x.json" + seen = [] + + def request(candidate, **kwargs): + seen.append(candidate) + if len(seen) == 1: + raise NetworkError("mirror down") + return Response(candidate, 200, body=b"bad" if len(seen) == 2 else DATA) + + with patch.object(http, "_request", request): + assert http.get_bytes(url, expected_sha1=hashlib.sha1(DATA).hexdigest()) == DATA + assert seen == list(download_candidates(url)[:3]) + + +def test_download_fallback_restarts_partial_files_and_counts_bytes(tmp_path, fake_http): + url = "https://piston-data.mojang.com/v1/x.jar" + candidates = download_candidates(url) + fake_http.candidates = download_candidates + fake_http.add(candidates[0], b"bad") + fake_http.add(candidates[1], DATA) + snapshots = [] + manager = DownloadManager(fake_http, retries=0, progress_callback=snapshots.append) + spec = DownloadSpec( + url, tmp_path / "x.jar", sha1=hashlib.sha1(DATA).hexdigest(), size=len(DATA) + ) + manager.download(spec) + assert spec.destination.read_bytes() == DATA + assert fake_http.calls == list(candidates[:2]) + assert snapshots[-1].bytes_done == len(DATA) + manager.download(spec) + assert snapshots[-1].bytes_done == len(DATA) + assert snapshots[-1].completed_tasks == 1 + + +def test_sha512_only_pack_is_verified_and_cached_files_are_rechecked(tmp_path, fake_http): + url = "https://cdn.modrinth.com/mod.jar" + fake_http.add(url, DATA) + spec = DownloadSpec( + url, tmp_path / "mod.jar", size=len(DATA), sha512=hashlib.sha512(DATA).hexdigest() + ) + manager = DownloadManager(fake_http, retries=0) + manager.download(spec) + assert manager.download(spec).skipped + spec.destination.write_bytes(b"x" * len(DATA)) + assert not manager.download(spec).skipped + bad = DownloadSpec(url, tmp_path / "bad.jar", sha512="0" * 128) + with pytest.raises(DownloadError): + manager.download(bad) + assert not bad.destination.exists() + + +def test_oauth_http400_protocol_error_body_is_preserved(): + http = HttpClient(HttpConfig(retries=0)) + auth = MicrosoftAuth(http, "client") + for error in ["authorization_pending", "slow_down", "authorization_declined", "expired_token"]: + body = json.dumps({"error": error}).encode() + response = urllib.error.HTTPError( + "https://login.live.com/token", 400, "Bad Request", {}, io.BytesIO(body) + ) + with patch("urllib.request.urlopen", side_effect=response): + assert ( + auth._form_post("https://login.live.com/token", {}, allow_error_body=True)["error"] + == error + ) + response = urllib.error.HTTPError( + "https://login.live.com/token", 400, "Bad Request", {}, io.BytesIO(b"{}") + ) + with patch("urllib.request.urlopen", side_effect=response), pytest.raises(NetworkError): + http.request("https://login.live.com/token", method="POST") + + +def test_stream_rejects_https_to_http_redirect_before_reading(): + class Raw: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def geturl(self): + return "http://attacker.test/mod.jar" + + def read(self, *args): + pytest.fail("downgraded body must not be consumed") + + with patch("urllib.request.urlopen", return_value=Raw()), pytest.raises(NetworkError): + list(HttpClient().stream("https://cdn.example.test/mod.jar")) + + +def test_macos_native_and_rule_names(): + assert rules_allow([Rule(os_name="osx")], platform_key="macos") + library = Library.from_dict( + { + "name": "test:native:1", + "natives": {"osx": "natives-osx"}, + "downloads": { + "classifiers": { + "natives-osx": {"path": "native.jar", "url": "https://x/native.jar"} + } + }, + } + ) + assert library.native_artifact("macos").path == "native.jar" + + +@pytest.mark.parametrize("value", [None, "bad", [], {}, True, float("nan"), float("inf")]) +def test_settings_invalid_types_fall_back(tmp_path, value): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "download_retries": value, + "log_level": value, + "download_timeout": value, + "schema_version": value, + } + ) + ) + settings = SettingsStore(path).load() + assert settings.download_retries == Settings().download_retries + assert settings.log_level == "INFO" + assert settings.download_timeout == Settings().download_timeout + + +def test_existing_root_permissions_are_preserved(tmp_path): + if os.name != "posix": + pytest.skip("POSIX modes") + tmp_path.chmod(0o755) + AppPaths(tmp_path).ensure() + assert tmp_path.stat().st_mode & 0o777 == 0o755 + assert (tmp_path / "data").stat().st_mode & 0o777 == 0o700 + + +def test_archive_export_omits_external_symlink(tmp_path): + root = tmp_path / "instance" + root.mkdir() + secret = tmp_path / "secret" + secret.write_text("private") + try: + (root / "leak").symlink_to(secret) + (root / "outside").symlink_to(tmp_path, target_is_directory=True) + except OSError: + pytest.skip("symlink creation unavailable") + (root / "safe").write_text("public") + target = zip_directory(root, tmp_path / "out.zip") + with zipfile.ZipFile(target) as zf: + assert zf.namelist() == ["safe"] + + +def test_restore_unsafe_backup_preserves_existing_worlds(repos, app_paths, tmp_path): + manager = InstanceManager(repos["instances"], app_paths) + instance = manager.create(name="worlds", mc_version="1.20.1") + world = Path(instance.directory) / "saves" / "world.dat" + world.write_bytes(DATA) + archive = tmp_path / "unsafe.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("../../escape", "bad") + with pytest.raises(PermissionError_): + manager.restore_backup(instance.id, archive) + assert world.read_bytes() == DATA + assert not list(Path(instance.directory).glob("restore-*")) + + +def test_overrides_use_shared_size_and_symlink_guards(tmp_path): + archive = tmp_path / "pack.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("overrides/config/big", b"x" * 1024) + with pytest.raises(ArchiveError): + extract_zip(archive, tmp_path / "dest", prefixes=("overrides",), max_total_bytes=16) + with zipfile.ZipFile(archive, "w") as zf: + info = zipfile.ZipInfo("overrides/link") + info.external_attr = 0o120777 << 16 + zf.writestr(info, "../../secret") + with pytest.raises(PermissionError_): + mrpack.extract_overrides(archive, tmp_path / "dest") + + +def test_version_metadata_hash_is_verified(fake_http, tmp_path): + url = "https://piston-meta.mojang.com/version.json" + fake_http.add_json(MANIFEST_URL, {"versions": [{"id": "test", "url": url, "sha1": "0" * 40}]}) + fake_http.add_json(url, {"id": "test", "mainClass": "tampered"}) + manifest = VersionManifest(fake_http, tmp_path) + with pytest.raises(IntegrityError): + manifest.download_version_json("test", tmp_path / "versions") + assert not (tmp_path / "versions/test/test.json").exists() + + +def test_instance_summary_preserves_editor_values(repos, app_paths): + manager = InstanceManager(repos["instances"], app_paths) + instance = manager.create(name="custom", mc_version="1.20.1", width=1600, height=900) + instance.jvm_args = "-Dcustom=true" + instance.game_args = "--demo" + projection = instance_summary(instance) + assert (projection["width"], projection["height"]) == (1600, 900) + assert projection["jvmArgs"] == instance.jvm_args + assert projection["gameArgs"] == instance.game_args + + +def test_custom_arguments_preserve_windows_paths_and_quoting(): + assert _split_extra(r'-Dconfig=C:\mods\config.json "-Dname=hello world"') == [ + r"-Dconfig=C:\mods\config.json", + "-Dname=hello world", + ] + + +def test_modpack_loader_failure_rolls_back_instance(tmp_path, repos, app_paths, fake_http): + archive = tmp_path / "pack.mrpack" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr( + mrpack.INDEX_NAME, + json.dumps( + { + "formatVersion": 1, + "dependencies": {"minecraft": "1.20.1", "fabric-loader": "missing"}, + "files": [], + } + ), + ) + instances = InstanceManager(repos["instances"], app_paths) + service = ModpackService( + instances, repos["mods"], DownloadManager(fake_http), http=fake_http, paths=app_paths + ) + with pytest.raises(LoaderError): + service.import_archive(archive) + assert instances.list() == [] + assert list(app_paths.instances.iterdir()) == [] + + +def test_exported_modpack_keeps_config_directory(tmp_path, repos, app_paths, fake_http): + instances = InstanceManager(repos["instances"], app_paths) + instance = instances.create(name="config", mc_version="1.20.1") + (Path(instance.directory) / "config/example.toml").write_text("x=1") + service = ModpackService( + instances, repos["mods"], DownloadManager(fake_http), http=fake_http, paths=app_paths + ) + archive = service.export_mrpack(instance, tmp_path / "export.mrpack") + target = tmp_path / "import" + mrpack.extract_overrides(archive, target) + assert (target / "config/example.toml").read_text() == "x=1" + + +def test_batches_serialize_without_resetting_active_progress(tmp_path, fake_http): + started = threading.Event() + release = threading.Event() + seen = [] + + def stream(url, **kwargs): + seen.append(url) + if url == "https://x/first": + started.set() + assert release.wait(5) + yield DATA + + fake_http.stream = stream + manager = DownloadManager(fake_http, retries=0) + errors = [] + + def run(name): + try: + manager.download(DownloadSpec("https://x/" + name, tmp_path / name, size=len(DATA))) + except Exception as exc: + errors.append(exc) + + first = threading.Thread(target=run, args=("first",)) + second = threading.Thread(target=run, args=("second",)) + first.start() + assert started.wait(5) + second.start() + time.sleep(0.05) + assert seen == ["https://x/first"] + release.set() + first.join(5) + second.join(5) + assert not errors + assert seen == ["https://x/first", "https://x/second"] + + +def test_legacy_asset_layouts_and_instance_resources(tmp_path, fake_http, app_paths): + from apeiron.mc.installer import JavaInstaller + from apeiron.mc.version import VersionDescriptor + + sha1 = hashlib.sha1(DATA).hexdigest() + index = { + "virtual": True, + "map_to_resources": True, + "objects": {"sounds/example.ogg": {"hash": sha1, "size": len(DATA)}}, + } + index_file = tmp_path / "index.json" + index_file.write_text(json.dumps(index)) + fake_http.add(f"https://resources.download.minecraft.net/{sha1[:2]}/{sha1}", DATA) + installer = JavaInstaller(fake_http, app_paths, DownloadManager(fake_http), None) + descriptor = VersionDescriptor(id="old", assets="legacy") + with patch.object(installer, "_download_asset_index", return_value=index_file): + installer._install_assets(descriptor, lambda stage: None) + assert (app_paths.assets / "virtual/legacy/sounds/example.ogg").read_bytes() == DATA + instance = tmp_path / "instance" + installer.materialize_resources(SimpleNamespace(asset_index="legacy"), instance) + assert (instance / "resources/sounds/example.ogg").read_bytes() == DATA + + +def test_neoforge_two_component_game_version(fake_http, app_paths): + from apeiron.loaders.forge import NeoForgeLoader + + loader = NeoForgeLoader(fake_http, app_paths) + fake_http.add( + f"{loader.maven_base}/net/neoforged/neoforge/maven-metadata.xml", + b"21.0.1021.1.1", + ) + assert [v.version for v in loader.versions("1.21")] == ["21.0.10"] + + +def test_content_categories_and_nested_paths_survive_import(tmp_path, fake_http, app_paths, repos): + entries = [] + for directory in ["mods", "resourcepacks", "shaderpacks", "datapacks"]: + url = f"https://cdn.example.test/{directory}.jar" + fake_http.add(url, DATA) + entries.append( + { + "path": f"{directory}/nested/example.jar", + "downloads": [url], + "hashes": {"sha512": hashlib.sha512(DATA).hexdigest()}, + "fileSize": len(DATA), + } + ) + archive = tmp_path / "pack.mrpack" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr( + mrpack.INDEX_NAME, + json.dumps( + {"formatVersion": 1, "dependencies": {"minecraft": "1.20.1"}, "files": entries} + ), + ) + service = ModpackService( + InstanceManager(repos["instances"], app_paths), + repos["mods"], + DownloadManager(fake_http), + http=fake_http, + paths=app_paths, + ) + result = service.import_archive(archive) + records = repos["mods"].list_for_instance(result.instance.id) + assert {r.kind for r in records} == {"mod", "resourcepack", "shaderpack", "datapack"} + for record in records: + assert record.filename == "nested/example.jar" + service.remove_content(result.instance, record) + assert not list(Path(result.instance.directory).rglob("*.jar")) + + +def test_curseforge_import_records_manageable_mods(tmp_path, fake_http, app_paths, repos): + archive = tmp_path / "pack.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr( + curseforge.MANIFEST_NAME, + json.dumps( + { + "name": "CF", + "minecraft": {"version": "1.20.1"}, + "files": [{"projectID": 1, "fileID": 2}], + } + ), + ) + fake_http.add_json( + f"{curseforge.API_BASE}/mods/1/files/2", + { + "data": { + "downloadUrl": "https://cdn.example.test/a.jar", + "fileName": "a.jar", + "fileLength": len(DATA), + } + }, + ) + fake_http.add("https://cdn.example.test/a.jar", DATA) + service = ModpackService( + InstanceManager(repos["instances"], app_paths), + repos["mods"], + DownloadManager(fake_http), + http=fake_http, + paths=app_paths, + curseforge_key="user-key", + ) + result = service.import_archive(archive) + records = repos["mods"].list_for_instance(result.instance.id) + assert len(records) == 1 + assert records[0].source == "curseforge" + service.remove_content(result.instance, records[0]) + assert not (Path(result.instance.directory) / "mods/a.jar").exists() + + +def test_launch_identity_failure_records_and_stop_duration(tmp_path, repos, app_paths, monkeypatch): + from apeiron.core.errors import LaunchError + from apeiron.services.launcher import LaunchService, LaunchState + + manager = InstanceManager(repos["instances"], app_paths) + instance = manager.create(name="launch", mc_version="1.20.1") + installer = SimpleNamespace(materialize_resources=lambda *args: None) + service = LaunchService( + app_paths, + None, + None, + installer, + repos["instances"], + repos["launches"], + java_runtimes=lambda: [], + default_java=lambda: "", + ) + monkeypatch.setattr(service, "ensure_installed", lambda *args: object()) + monkeypatch.setattr( + service, "choose_java", lambda *args: SimpleNamespace(path=tmp_path / "java") + ) + options = [] + monkeypatch.setattr( + "apeiron.services.launcher.build_command", + lambda installed, opt: options.append(opt) or ["java"], + ) + + class Process: + pid = 123 + running = True + fail = True + + def __init__(self, *args, **kwargs): + self.callback = kwargs["on_exit"] + + def start(self): + if self.fail: + raise OSError("cannot execute Java") + + def stop(self): + self.running = False + return 0 + + monkeypatch.setattr("apeiron.services.launcher.GameProcess", Process) + with pytest.raises(OSError): + service.launch(instance, player_name="test", uuid="a", client_id="client-id") + assert repos["launches"].recent(1)[0].status == "failed" + assert options[0].client_id == "client-id" + Process.fail = False + process = service.launch(instance, player_name="test", uuid="a") + with pytest.raises(LaunchError): + service.delete_instance(instance.id, manager.delete) + assert manager.get(instance.id) + # Capture timestamp belongs to this launch even if state changes during stop. + with patch("apeiron.services.launcher.time.time", return_value=service.state.started_at + 12): + service.stop() + service.state = LaunchState() + process.callback(0) + assert ( + next(r for r in repos["launches"].recent(10) if r.status == "exited").duration_seconds == 12 + ) + + +def test_file_url_conversion_and_backend_completion(app_paths, monkeypatch): + from PySide6.QtCore import QCoreApplication, QUrl + + from apeiron.services.app_context import build_context + from apeiron.ui.backend import Backend, local_path + + app = QCoreApplication.instance() or QCoreApplication([]) + target = app_paths.root / "中文 file.zip" + assert local_path(QUrl.fromLocalFile(str(target)).toString()) == target + context = build_context(root=app_paths.root) + backend = Backend(context) + callbacks = {} + monkeypatch.setattr( + backend, "_run", lambda task, fn, on_done=None: callbacks.update({task: (fn, on_done)}) + ) + backend.installJava(21) + runtime = app_paths.java / "bin/java" + callbacks["install-java"][1]({"path": str(runtime), "version": "21"}) + assert context.settings.java_path == str(runtime) + backend._download_progress({"totalTasks": 1, "completedTasks": 1, "failedTasks": 0}) + assert backend.status["kind"] == "idle" + stopped = [] + monkeypatch.setattr(context.launcher, "stop", lambda: stopped.append(True)) + backend.shutdown(0) + assert stopped == [] + context.settings_store.update(close_game_on_launcher_exit=True) + backend.shutdown(0) + assert stopped == [True] + context.database.close() + assert app is not None diff --git a/tools/build_translations.py b/tools/build_translations.py index 63406bb..46352e7 100644 --- a/tools/build_translations.py +++ b/tools/build_translations.py @@ -132,6 +132,8 @@ "Game library": "游戏库", "Every instance has its own directory, mods, settings and account.": "每个实例都有独立的目录、模组、设置与账户。", "New instance": "新建实例", + "Run loader installer?": "运行加载器安装程序?", + "Forge and NeoForge installation runs downloaded Java code. Continue only if you trust the loader.": "安装 Forge 和 NeoForge 会执行下载的 Java 代码。仅在信任该加载器时继续。", "Import": "导入", "Your library is empty": "游戏库为空", "Create an instance to download Minecraft and start playing.": "创建一个实例以下载 Minecraft 并开始游戏。", From 66be6d89da919d5dda12d3689c865c41f62009fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Wed, 28 Oct 2026 22:48:13 +0800 Subject: [PATCH 2/5] fix: retain source fallback for mirrored URLs returned by manifests --- docs/DOWNLOAD_SOURCES.md | 4 +++- docs/TEST_REPORT.md | 13 ++++++++++--- src/apeiron/net/sources.py | 22 ++++++++++++++++++++++ tests/test_review_regressions.py | 9 +++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/DOWNLOAD_SOURCES.md b/docs/DOWNLOAD_SOURCES.md index 61307b9..a8b4ca0 100644 --- a/docs/DOWNLOAD_SOURCES.md +++ b/docs/DOWNLOAD_SOURCES.md @@ -18,7 +18,9 @@ Only explicitly recognized public download hosts are rewritten. Microsoft/Xbox/M account APIs, CurseForge API calls, Modrinth downloads and arbitrary modpack URLs retain their original destinations. HTTPS is required, including after streaming redirects. -A failed or corrupt mirror advances to the next source. Each file attempt starts a new +Already-rewritten links returned in mirror manifests are routed through the same +priority chain with an official upstream fallback. A failed or corrupt mirror advances +to the next source. Each file attempt starts a new `.part` file, verifies size and all supplied SHA-1/SHA-512 digests, and atomically commits only verified bytes. Download progress counts successful bytes once and removes bytes from discarded attempts. Independent batches are serialized so their cancellation and diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 48c1430..bb909a0 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -287,6 +287,7 @@ real digests and successful loader installation is explicitly stubbed. Verified locally on Linux / Python 3.12 / PySide6 6.9.3: - C11 harness: 44 checks, 0 failures. +- Pytest: 217 passed, including 45 new regression cases and the headless GUI smoke. - Ruff and mypy: clean after remediation. - Mirror mapping, priority, corrupt-source fallback, SHA-512-only downloads, streaming downgrade rejection and OAuth HTTP 400 protocol states are exercised with controlled @@ -301,6 +302,12 @@ identified as `Apeiron`/`apeiron` filesystem collision; Windows failed its other successful package self-check because offscreen Qt did not discover the Windows fonts. Both causes are addressed in this revision. Cross-platform package results must be read from the follow-up PR's Actions runs; local Linux tests do not certify Windows/macOS. -Live mirror reachability and a licensed Microsoft sign-in are not certified by these -mocked protocol tests. The local network returned HTTP 503 for the bsmirror manifest -probe. See `docs/DOWNLOAD_SOURCES.md` for routing and configuration. +Online spot checks reached the mirror.hkmc.online and BMCLAPI version manifests (921 +entries each), then fetched Minecraft 1.20.1 version JSON and a 1,330,045-byte library +with checksum verification. bsmirror returned 503 or timed out in this environment. +The probe exposed already-rewritten mirror URLs in manifest entries; those now retain +the full priority chain and a reconstructed official fallback (three additional tests). +A licensed Microsoft sign-in and sustained mirror availability are not certified. +The local Nuitka build reached GCC but could not link because this environment's Python +runtime lacks its referenced `lib/libpython3.12.so`; hosted packaging is checked separately. +See `docs/DOWNLOAD_SOURCES.md` for routing and configuration. diff --git a/src/apeiron/net/sources.py b/src/apeiron/net/sources.py index f97f7ce..a54dfb9 100644 --- a/src/apeiron/net/sources.py +++ b/src/apeiron/net/sources.py @@ -30,6 +30,28 @@ def download_candidates(url: str) -> tuple[str, ...]: return (url,) host = parsed.hostname path = parsed.path + if host in {urlsplit(root).hostname for root in MIRROR_ROOTS}: + # BMCL-compatible manifests may already contain mirror URLs. Recover + # the upstream before routing so those links retain all four fallbacks. + if path.startswith("/assets/"): + upstream, path = "resources.download.minecraft.net", path.removeprefix("/assets") + elif path.startswith("/fabric-meta/"): + upstream, path = "meta.fabricmc.net", path.removeprefix("/fabric-meta") + elif path.startswith("/maven/net/neoforged/"): + upstream, path = "maven.neoforged.net", "/releases" + path.removeprefix("/maven") + elif path.startswith("/maven/net/minecraftforge/"): + upstream, path = "maven.minecraftforge.net", path.removeprefix("/maven") + elif path.startswith("/maven/"): + upstream, path = "libraries.minecraft.net", path.removeprefix("/maven") + if path.startswith("/net/fabricmc/"): + upstream = "maven.fabricmc.net" + elif path.startswith("/v1/objects/"): + upstream = "piston-data.mojang.com" + elif path.startswith(("/v1/packages/", "/mc/game/", "/v1/products/")): + upstream = "piston-meta.mojang.com" + else: + return (url,) + return download_candidates(urlunsplit(("https", upstream, path, parsed.query, ""))) if host in MOJANG_HOSTS: relative = path elif host == "resources.download.minecraft.net": diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py index aa88e0a..f71ba65 100644 --- a/tests/test_review_regressions.py +++ b/tests/test_review_regressions.py @@ -558,3 +558,12 @@ def test_file_url_conversion_and_backend_completion(app_paths, monkeypatch): assert stopped == [True] context.database.close() assert app is not None + + +@pytest.mark.parametrize("root", MIRROR_ROOTS) +def test_embedded_mirror_metadata_urls_keep_all_fallbacks(root): + path = "/v1/packages/abc/1.20.1.json" + assert download_candidates(root + path) == ( + *(mirror + path for mirror in MIRROR_ROOTS), + "https://piston-meta.mojang.com" + path, + ) From 549588bda4a8fb80d99caa5c5fdb784df7ee1212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Wed, 28 Oct 2026 23:00:41 +0800 Subject: [PATCH 3/5] fix: initialize installed fonts during packaged self-check --- docs/TEST_REPORT.md | 7 +++++++ src/apeiron/__main__.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index bb909a0..c4c12e7 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -311,3 +311,10 @@ A licensed Microsoft sign-in and sustained mirror availability are not certified The local Nuitka build reached GCC but could not link because this environment's Python runtime lacks its referenced `lib/libpython3.12.so`; hosted packaging is checked separately. See `docs/DOWNLOAD_SOURCES.md` for routing and configuration. + + +The first follow-up package run passed Linux and Windows, and macOS successfully +compiled and loaded its native extension, QML, translations and database. Its strict +self-check then caught an absent default `Sans Serif` family. Self-check now calls the +same installed-font selection routine as normal GUI startup, rather than suppressing +the warning. The corresponding follow-up Actions run validates the final package. diff --git a/src/apeiron/__main__.py b/src/apeiron/__main__.py index 0e92d0d..3d42719 100644 --- a/src/apeiron/__main__.py +++ b/src/apeiron/__main__.py @@ -50,7 +50,7 @@ def _self_check() -> int: from PySide6.QtCore import QtMsgType, qInstallMessageHandler from apeiron.services.app_context import build_context - from apeiron.ui.app import create_engine + from apeiron.ui.app import _configure_font, create_engine # Collect Qt/QML diagnostics emitted while the interface is constructed so # that a broken QML binding fails the check instead of scrolling past. @@ -61,6 +61,7 @@ def _handler(mode, context_, message): messages.append(message) qInstallMessageHandler(_handler) + _configure_font(app) context = build_context() checks["database"] = { From 2e9f4d5ea2951953b0dd05c2d236d440e18bbb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Wed, 28 Oct 2026 23:06:39 +0800 Subject: [PATCH 4/5] fix: use the system fixed font for the log view --- docs/TEST_REPORT.md | 4 ++++ src/apeiron/ui/backend.py | 5 +++++ src/apeiron/ui/qml/pages/LogsPage.qml | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index c4c12e7..01d52d1 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -318,3 +318,7 @@ compiled and loaded its native extension, QML, translations and database. Its st self-check then caught an absent default `Sans Serif` family. Self-check now calls the same installed-font selection routine as normal GUI startup, rather than suppressing the warning. The corresponding follow-up Actions run validates the final package. + +The next macOS self-check confirmed the UI font repair and exposed the log page's +hardcoded `Monospace` family. That page now uses Qt's system fixed-font family through +the backend; neither of the missing-family warnings is whitelisted. diff --git a/src/apeiron/ui/backend.py b/src/apeiron/ui/backend.py index 331ef8e..dc6d29f 100644 --- a/src/apeiron/ui/backend.py +++ b/src/apeiron/ui/backend.py @@ -18,6 +18,7 @@ from typing import Any from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, QUrl, Signal, Slot +from PySide6.QtGui import QFontDatabase from apeiron.core import __version__ from apeiron.core.errors import ApeironError @@ -171,6 +172,10 @@ def selectedInstance(self) -> dict[str, Any]: def selectedInstanceId(self) -> str: return self._selected_id + @Property(str, constant=True) + def fixedFontFamily(self) -> str: + return QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family() + @Property(str, notify=consoleChanged) def consoleText(self) -> str: return self._console diff --git a/src/apeiron/ui/qml/pages/LogsPage.qml b/src/apeiron/ui/qml/pages/LogsPage.qml index 09e1e8e..91e15d9 100644 --- a/src/apeiron/ui/qml/pages/LogsPage.qml +++ b/src/apeiron/ui/qml/pages/LogsPage.qml @@ -50,7 +50,7 @@ Item { width: flick.width readOnly: true wrapMode: TextArea.WrapAnywhere - font.family: "monospace" + font.family: App.fixedFontFamily font.pixelSize: 12 color: Theme.onSurface text: page.tab === 0 ? (App.consoleText.length > 0 ? App.consoleText : qsTr("No game output yet.")) From ad611da8c1864cec3a21b4b65f0fbfca8d2714ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Wed, 28 Oct 2026 23:11:24 +0800 Subject: [PATCH 5/5] fix: resolve generic fixed fonts to installed faces in offscreen Qt --- docs/TEST_REPORT.md | 5 +++++ src/apeiron/ui/backend.py | 11 ++++++++++- tools/gui_smoke.py | 8 +++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 01d52d1..bd4d1a1 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -322,3 +322,8 @@ the warning. The corresponding follow-up Actions run validates the final package The next macOS self-check confirmed the UI font repair and exposed the log page's hardcoded `Monospace` family. That page now uses Qt's system fixed-font family through the backend; neither of the missing-family warnings is whitelisted. + +Qt offscreen can return the generic uninstalled name even from `systemFont(FixedFont)` +(reproduced locally as `monospace`, absent from the installed family list). The log-font +selection therefore checks installed families and explicitly selects a fixed-pitch face +when needed. The GUI smoke checks this installed-font contract on the host. diff --git a/src/apeiron/ui/backend.py b/src/apeiron/ui/backend.py index dc6d29f..6384374 100644 --- a/src/apeiron/ui/backend.py +++ b/src/apeiron/ui/backend.py @@ -174,7 +174,16 @@ def selectedInstanceId(self) -> str: @Property(str, constant=True) def fixedFontFamily(self) -> str: - return QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family() + families = QFontDatabase.families() + preferred = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family() + if preferred in families: + return preferred + # Offscreen platforms may return a generic, uninstalled family even + # from systemFont(). Select an installed fixed-pitch face explicitly. + return next( + (family for family in families if QFontDatabase.isFixedPitch(family)), + families[0] if families else "", + ) @Property(str, notify=consoleChanged) def consoleText(self) -> str: diff --git a/tools/gui_smoke.py b/tools/gui_smoke.py index f875ca0..5ac6d8f 100644 --- a/tools/gui_smoke.py +++ b/tools/gui_smoke.py @@ -69,7 +69,7 @@ def walk(item) -> None: def main(data_dir: str) -> int: from PySide6.QtCore import QtMsgType, qInstallMessageHandler - from PySide6.QtGui import QGuiApplication + from PySide6.QtGui import QFontDatabase, QGuiApplication from PySide6.QtQuick import QQuickItem from PySide6.QtQuickControls2 import QQuickStyle @@ -97,6 +97,12 @@ def collect(mode, context, message): context = build_context() engine, backend = create_engine(context) roots = engine.rootObjects() + fixed_family = backend.fixedFontFamily + check( + "font.fixed_installed", + fixed_family in QFontDatabase.families() and QFontDatabase.isFixedPitch(fixed_family), + fixed_family, + ) check("window.loaded", len(roots) == 1, f"roots={len(roots)}") if not roots: print(json.dumps(RESULT, ensure_ascii=False))