Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Build
on:
push:
tags: ["v*"]
branches: ["main"]
pull_request:
paths:
- "src/**"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/DOWNLOAD_SOURCES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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.

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
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.
51 changes: 51 additions & 0 deletions docs/TEST_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,54 @@ 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.
- 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
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.
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.


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.

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.
22 changes: 10 additions & 12 deletions packaging/build_nuitka.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*"))
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 5 additions & 1 deletion src/apeiron/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -47,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.
Expand All @@ -58,6 +61,7 @@ def _handler(mode, context_, message):
messages.append(message)

qInstallMessageHandler(_handler)
_configure_font(app)

context = build_context()
checks["database"] = {
Expand Down
7 changes: 3 additions & 4 deletions src/apeiron/auth/microsoft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions src/apeiron/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", "")),
Expand Down
33 changes: 29 additions & 4 deletions src/apeiron/core/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from __future__ import annotations

import os
import shutil
import tarfile
import zipfile
from collections.abc import Iterable
Expand Down Expand Up @@ -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)
Expand All @@ -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}")
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Loading
Loading