69 Commits

Author SHA1 Message Date
c278efa339 Released version 1.0.2 2026-09-12 11:50:04 +02:00
f3059c334e Reworked some game window and gamescope methods for Cinnamon. 2026-09-12 11:50:04 +02:00
2cbb9315bf Added more folders and restructured classes inside them. 2026-09-12 11:50:04 +02:00
be7a4782e8 Reworked the project structure. 2026-09-12 11:50:04 +02:00
15741f5f8a Delete RELEASE_PIPELINE_WORKPLAN.md 2026-09-10 22:15:13 +02:00
0a89323533 Add channel-aware release tooling and updater recovery 2026-09-10 00:39:37 +02:00
cc48bf45d2 Publish signed launcher release history 2026-09-10 00:38:33 +02:00
84f8de543b Upload files to "updates" 2026-09-10 00:29:46 +02:00
1329485ec5 Delete updates/stable.json.sig 2026-09-10 00:29:27 +02:00
8f1655b241 Delete updates/stable.json 2026-09-10 00:29:23 +02:00
be71d31c48 Fix v0.9.11 archive download cache 2026-09-10 00:23:54 +02:00
46e11ae8d5 Upload files to "updates" 2026-09-10 00:15:20 +02:00
eeb993efc9 Delete updates/stable.json.sig 2026-09-10 00:14:32 +02:00
a90bd19858 Delete updates/stable.json 2026-09-10 00:14:27 +02:00
9644de3ff5 Fixed some UI issues with updater. 2026-09-10 00:13:05 +02:00
a362b0bb8e Updated to new release v0.9.10 2026-09-09 23:12:47 +02:00
27ed039f32 Updated to new release v0.9.9 2026-09-09 23:01:49 +02:00
290d1a5383 Added better support for kubuntu and fixed some issues with telemetry. 2026-09-09 22:35:13 +02:00
6e6219107e Updated diagnostic message. 2026-09-09 22:34:34 +02:00
5770969629 Updated the update system to support multi channels (stable / pre-release). 2026-09-09 20:52:00 +02:00
2a53a59cef Released a new version, 0.9.8. 2026-09-08 22:51:19 +02:00
76bef94757 Fixed more issues with Cinnamon window resizing and popups. 2026-09-08 22:46:53 +02:00
1a120bbf86 Fixed issues with telemetry. 2026-09-08 22:46:20 +02:00
9b0abf6223 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	updates/stable.json
#	updates/stable.json.sig
2026-09-07 22:26:26 +02:00
108ea94fb7 Released new stable version 0.9.7. 2026-09-07 22:25:45 +02:00
1cfe8625e9 Added a fix for X11 / Cinnamon window popups and maximize button not visible. 2026-09-07 22:19:05 +02:00
f06fc2e4e3 Added telemetry system to get information about issues. 2026-09-07 21:49:37 +02:00
a1ff4794ae Upload files to "updates" 2026-09-07 00:12:19 +02:00
6eea3b2f2b Delete updates/stable.json.sig 2026-09-07 00:12:00 +02:00
ee9bb064ff Update updates/stable.json 2026-09-07 00:11:47 +02:00
bea9d6fda7 Fixed issues with gamescope not properly launching. 2026-09-06 22:57:54 +02:00
f53f4d0b08 Released new version 0.9.5 2026-09-06 22:09:16 +02:00
3cbe4f72b0 Fixed the on top issue with the game window, it should not pop up when alt tabbing to some other programs now. 2026-09-06 22:03:12 +02:00
6eab4b961f Fixed keyboard / input issues with resizing. 2026-09-06 21:52:07 +02:00
0963ae34b9 Released version 0.9.3 2026-09-06 21:06:48 +02:00
f9a6d6aed0 Updated settings modal layout. 2026-09-06 20:59:48 +02:00
3a01c6ba2a Reworked the gamescope to work better with scaling and maximize window. 2026-09-06 10:57:42 +02:00
f0d631be66 Added asset checks and fixes for missing data in game client. 2026-09-05 21:52:41 +02:00
cccce05a99 Released a new version 0.9.1 2026-09-04 23:43:06 +02:00
09c8f165be Reworked some umu / proton 7 maximize window and keyboard issues. 2026-09-04 23:39:43 +02:00
8fe627b6cf Updated to new version 0.9.0 2026-09-03 23:22:48 +02:00
598d761af4 Added maximize window and free scaling support. 2026-09-03 23:12:48 +02:00
5be4ace6c2 Added bubblewrap / apparmor fixes and diagnostic tools for troubleshooting it on related distros. 2026-09-03 22:16:43 +02:00
ecdea827ac Code cleanup and improvements. 2026-09-03 21:52:06 +02:00
ea323e3ab0 Reworked UI for easier workflow. 2026-09-03 19:07:09 +02:00
c61254e4a9 Added download settings and optimized the downloader system with concurrency. 2026-09-03 18:26:10 +02:00
ba079ef6fa Added resolution settings and updater modal with rollback functionality for launcher versions. 2026-09-02 23:18:16 +02:00
bb90efa9d1 Released version 0.8.5 2026-09-02 21:47:21 +02:00
7ce6ac3dd7 Added umu download and repair for missing components on install, update and manual repair. 2026-09-02 21:42:49 +02:00
6f20b5e359 Fixed issues with windowed mode and fullscreen. 2026-09-02 20:55:00 +02:00
55b2292bfe Released v0.8.3 2026-09-01 22:26:19 +02:00
15dbd90dbb Optimizations and performance boost to pre-loader for launcher and game client. 2026-09-01 22:21:54 +02:00
c2400db19e Changed how borderless setting works. 2026-09-01 22:06:00 +02:00
1a17c1a197 Fixed a gamebox issue where resolution would act up on launch. 2026-09-01 22:05:33 +02:00
25f490c8b8 Hotfixed a issue with resolution in borderless mode. 2026-09-01 20:51:45 +02:00
e8d77f3779 Released v0.8.1. 2026-09-01 20:17:37 +02:00
6900c20734 Prepare release v0.8.1 2026-09-01 20:09:16 +02:00
f96aba9d99 Fixed multiple clients bug in umu. 2026-09-01 20:05:54 +02:00
78dade0d86 Fixed some proton 7 path issues and the override in settings. 2026-09-01 19:07:00 +02:00
d819809197 New release: v0.8.0 2026-08-31 23:30:39 +02:00
dadae8f60a Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over. 2026-08-31 23:23:37 +02:00
99982b12d9 Added debugger class for dashboard and other launcher util classes. 2026-08-31 23:13:04 +02:00
75b21acf87 Added windows identity checker. 2026-08-31 23:12:20 +02:00
b16540099d Added changelog class 2026-08-31 23:11:21 +02:00
b0855ccd9b Added import class. 2026-08-31 23:10:38 +02:00
87dee93b86 Reworked the modal ui's for launcher, settings, updates and game pre-loader. 2026-08-31 22:03:47 +02:00
fe883047e0 Added a debug system and fixed thread issues with update / repair. 2026-08-31 20:23:23 +02:00
20a0bbcd75 Removed player counter. 2026-08-31 17:46:08 +02:00
ed0c278a8b Released v0.7.0. 2026-08-30 22:53:00 +02:00
215 changed files with 44527 additions and 9914 deletions

View File

@@ -0,0 +1,75 @@
name: Publish launcher release status
on:
# Manual-release mode is active. Restore the status-file push trigger when
# automatic channel/history publication is wanted again.
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
history:
name: Sign and publish release history
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Check out trusted release metadata
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Validate release status configuration
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required metadata command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux scripts
python3 scripts/generate_history.py --help >/dev/null
bash -n scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for metadata publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Publish signed release status and history
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-release-history.sh ./dist/status
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

View File

@@ -0,0 +1,164 @@
name: Publish launcher release
# Manual-release mode is active. Restore the v* tag-push trigger when automatic
# Gitea publication is wanted again.
on:
workflow_dispatch:
concurrency:
group: launcher-release-publication
cancel-in-progress: false
jobs:
release:
name: Build, verify, and publish tagged release
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Check out the exact tag
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Validate and derive release metadata
id: release
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
if [[ "$(git cat-file -t "$TAG")" != "tag" ]]
then
printf 'Release tag must be annotated: %s\n' "$TAG" >&2
exit 1
fi
python3 -m ptlinux.update_resolver inspect-tag "$TAG" > release-metadata.json
VERSION="$(jq -er '.version' release-metadata.json)"
CHANNEL="$(jq -er '.channel' release-metadata.json)"
PRERELEASE="$(jq -er '.prerelease' release-metadata.json)"
COMMIT="$(git rev-parse "${TAG}^{commit}")"
if [[ "$COMMIT" != "$(git rev-parse HEAD)" ]]
then
printf 'Checkout does not match tagged commit %s.\n' "$COMMIT" >&2
exit 1
fi
mapfile -t OTHER_RELEASE_TAGS < <(
git tag --list | while IFS= read -r candidate
do
[[ "$candidate" == "$TAG" ]] || printf '%s\n' "$candidate"
done
)
python3 -m ptlinux.update_resolver validate-next \
"$CHANNEL" "$VERSION" "${OTHER_RELEASE_TAGS[@]}"
{
printf 'tag=%s\n' "$TAG"
printf 'version=%s\n' "$VERSION"
printf 'channel=%s\n' "$CHANNEL"
printf 'prerelease=%s\n' "$PRERELEASE"
printf 'commit=%s\n' "$COMMIT"
} >> "$GITHUB_OUTPUT"
- name: Recover the previewed release notes
shell: bash
run: |
set -euo pipefail
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
git for-each-ref --format='%(contents)' \
"refs/tags/${{ steps.release.outputs.tag }}" > "$NOTES_FILE"
if [[ ! -s "$NOTES_FILE" ]]
then
printf '%s\n' 'Annotated release tag has no release notes.' >&2
exit 1
fi
printf 'RELEASE_NOTES_FILE=%s\n' "$NOTES_FILE" >> "$GITHUB_ENV"
- name: Run release preflight checks
shell: bash
run: |
set -euo pipefail
for command in git bash curl jq python3 tar gzip sha256sum rsync openssl base64 realpath stat
do
command -v "$command" >/dev/null || {
printf 'Required release command is unavailable: %s\n' "$command" >&2
exit 1
}
done
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
python3 -m compileall -q ptlinux ptdiag scripts
python3 -m unittest discover -s release_tests -p 'test_*.py'
python3 -m unittest discover -s tests -p 'test_*.py'
bash -n scripts/release.sh scripts/build-release.sh \
scripts/verify-release.sh scripts/publish-gitea-release.sh \
scripts/publish-release-history.sh
- name: Materialize the protected manifest signing key
shell: bash
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${MANIFEST_SIGNING_KEY:-}" ]]
then
printf '%s\n' 'MANIFEST_SIGNING_KEY is required for release publication.' >&2
exit 1
fi
umask 077
SIGNING_KEY_FILE="${RUNNER_TEMP}/launcher-manifest-signing-key.pem"
printf '%s\n' "$MANIFEST_SIGNING_KEY" > "$SIGNING_KEY_FILE"
chmod 0600 "$SIGNING_KEY_FILE"
printf 'PTLINUX_SIGNING_KEY=%s\n' "$SIGNING_KEY_FILE" >> "$GITHUB_ENV"
- name: Build deterministic release artifacts
shell: bash
env:
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH="$(git show -s --format=%ct '${{ steps.release.outputs.commit }}')"
./scripts/build-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Verify complete release artifacts
shell: bash
env:
REQUIRE_RELEASE_SIGNATURE: "1"
run: |
set -euo pipefail
./scripts/verify-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist
- name: Create Gitea release, upload assets, and update channel metadata
shell: bash
env:
RELEASE_BRANCH: main
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
RELEASE_PRERELEASE: ${{ steps.release.outputs.prerelease }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
./scripts/publish-gitea-release.sh \
'${{ steps.release.outputs.version }}' \
'${{ steps.release.outputs.channel }}' \
./dist \
"$RELEASE_NOTES_FILE"
- name: Remove signing key
if: always()
shell: bash
run: |
if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]]
then
rm -f -- "$PTLINUX_SIGNING_KEY"
fi

33
.gitignore vendored
View File

@@ -3,8 +3,35 @@ __pycache__/
*.py[cod]
.pytest_cache/
.idea/
dist/
# Legacy safety net only. Release tooling requires private keys outside the
# checkout and generated artifact tree.
release-keys/
config/local.toml
results/*
!results/.gitkeep
diagnostics/config/local.toml
# Generated builds, candidates, runtime evidence, and machine-local state.
.artifacts/
# Legacy output locations remain ignored while existing local data is migrated
# manually. New commands write only below .artifacts/.
candidates/
dist/
replay_pid*.log
results/
runner-data/
runner-token
infra/gitea-runner/.env
infra/gitea-runner/runner-data/
infra/gitea-runner/runner-token
server/telemetry/.env
/diagnostics/
/devtools/
/docs/
/infra/
/artifacts/
/release_tests/
/tests/
/tools/
/.gitea/
/.run/

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux
Version 0.6.7 installs, updates, repairs, and launches PristonTale EU through a
Version 0.9.11 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not
included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-6-7.tar.gz.sig \
pristontale-eu-linux-installer-v0-6-7.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-6-7.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-6-7.tar.gz
cd pristontale-eu-linux-installer-v0-6-7
-signature pristontale-eu-linux-installer-v0-9-11.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-11.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-11.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-11.tar.gz
cd pristontale-eu-linux-installer-v0-9-11
./install.sh
```
@@ -34,6 +34,11 @@ Existing installations at the previous default location are preserved during
upgrades. The installer adds **PristonTale EU** to the application menu and
installs the `pristontale-eu` command in `~/.local/bin`.
Steam Proton 7.0 is discovered in both the default Steam library and secondary
libraries registered with the Steam client. If Proton uses an unregistered
manual layout, open **Settings → Optional Overrides** and select the exact
`Proton 7.0/proton` script.
## Use
Open **PristonTale EU** from the application menu or run:
@@ -51,6 +56,9 @@ The main launcher button automatically shows **Install**, **Update**, or
pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components
pristontale-eu runtime-bootstrap Prepare the verified UMU helper
pristontale-eu container-host Test the Bubblewrap sandbox
pristontale-eu container-host --repair Repair recognized Ubuntu AppArmor states
pristontale-eu login-diagnostics Check the login route without credentials
pristontale-eu login-diagnostics --runtime proton7
Compare the direct-Proton prefix read-only
@@ -78,4 +86,5 @@ pristontale-eu cache-compatibility PATH Cache a verified compatibility pai
```
Use `./install.sh --dry-run` to preview dependency installation or
`./install.sh --skip-deps` to manage distribution packages manually.
`./install.sh --skip-deps` to manage distribution packages and the host sandbox
manually.

View File

@@ -3,11 +3,27 @@
set -euo pipefail
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")"
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
if [[ -d "${SOURCE_ROOT}/src/ptlinux" ]]
then
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
PACKAGE_SOURCE_ROOT="${SOURCE_ROOT}/src"
else
# Bootstrap archives retain the historical top-level package layout.
PACKAGE_SOURCE_ROOT="$SOURCE_ROOT"
fi
if [[ -d "${SOURCE_ROOT}/native/gamescope" ]]
then
GAMESCOPE_PROVENANCE_ROOT="${SOURCE_ROOT}/native/gamescope"
GAMESCOPE_PATCH_ROOT="${GAMESCOPE_PROVENANCE_ROOT}/patches"
else
GAMESCOPE_PROVENANCE_ROOT="${PACKAGE_SOURCE_ROOT}/ptlinux/native"
GAMESCOPE_PATCH_ROOT="$GAMESCOPE_PROVENANCE_ROOT"
fi
SEMVER_PATTERN='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(beta|rc)\.(0|[1-9][0-9]*))?'
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${PACKAGE_SOURCE_ROOT}/ptlinux/__init__.py")"
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
if [[ ! "$VERSION" =~ ^${SEMVER_PATTERN}$ ]]
then
printf 'Invalid launcher version: %s (expected X.Y.Z, X.Y.Z-beta.N, or X.Y.Z-rc.N)\n' "$VERSION" >&2
exit 2
fi
if [[ "$VERSION" != "$SOURCE_VERSION" ]]
@@ -15,6 +31,14 @@ then
printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2
exit 2
fi
case "$VERSION" in
*-beta.*|*-rc.*)
RELEASE_CHANNEL="prerelease"
;;
*)
RELEASE_CHANNEL="stable"
;;
esac
DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
BIN_HOME="${HOME}/.local/bin"
APPLICATION_HOME="${DATA_HOME}/applications"
@@ -35,14 +59,18 @@ PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
DRY_RUN=0
RUN_DOCTOR=1
INSTALL_DEPENDENCIES=1
RUN_RUNTIME_BOOTSTRAP=1
usage()
{
printf '%s\n' \
"Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor]" \
"Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor] [--skip-runtime-bootstrap]" \
"" \
" --install-dir PATH Store the app, managed game, and runtime data at PATH." \
" Default: ~/Games/PristontaleEU"
" Default: ~/Games/PristontaleEU" \
" --skip-deps Do not install packages or repair host AppArmor profiles." \
" --skip-runtime-bootstrap" \
" Do not prepare the verified UMU helper during install."
}
while (( $# > 0 ))
@@ -75,6 +103,10 @@ do
INSTALL_DEPENDENCIES=0
shift
;;
--skip-runtime-bootstrap)
RUN_RUNTIME_BOOTSTRAP=0
shift
;;
-h|--help)
usage
exit 0
@@ -136,15 +168,30 @@ APP_ROOT="${INSTALL_DIRECTORY}/app"
RELEASES_ROOT="${APP_ROOT}/releases"
RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \
|| ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
if [[ ! -d "${PACKAGE_SOURCE_ROOT}/ptlinux" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/assbin.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/__init__.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptdiag/logs.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/self_update.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/__init__.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/ui/dashboard.py" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/ptr-bcrypt-proxy.dll" \
|| ! -f "${PACKAGE_SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \
|| ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.README" \
|| ! -f "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.copyright" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-lazy-output-images.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-live-resize.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-letterbox-backing.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-wayland-libdecor-input-scope.patch" \
|| ! -f "${GAMESCOPE_PATCH_ROOT}/gamescope-keep-nested-windows-visible.patch" \
|| ! -f "${SOURCE_ROOT}/scripts/__init__.py" \
|| ! -f "${SOURCE_ROOT}/scripts/repository_paths.py" \
|| ! -f "${SOURCE_ROOT}/scripts/release_lib.py" \
|| ! -f "${SOURCE_ROOT}/release/config.json" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
@@ -175,6 +222,11 @@ fi
if (( DRY_RUN == 1 ))
then
if (( INSTALL_DEPENDENCIES == 1 ))
then
printf '%s\n' \
"Dry run: installation would probe Bubblewrap and offer only the guarded Ubuntu AppArmor repair."
fi
exit 0
fi
@@ -189,6 +241,13 @@ then
printf '%s\n' "Python 3.11 or newer is required." >&2
exit 1
fi
if ! "$PYTHON_BIN" -I -B -c \
'import sys; sys.path.insert(0, sys.argv[1]); from ptlinux.update_resolver import validate_channel_version; validate_channel_version(sys.argv[2], sys.argv[3])' \
"$PACKAGE_SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION"
then
printf '%s\n' "Launcher version and release channel validation failed." >&2
exit 1
fi
install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME"
STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging"
@@ -197,30 +256,19 @@ then
printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2
exit 1
fi
install -d -m 0755 "$STAGING_RELEASE"
install -d -m 0755 \
"${STAGING_RELEASE}/ptlinux" \
"${STAGING_RELEASE}/ptlinux/native" \
"${STAGING_RELEASE}/ptdiag"
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py
do
case "${SOURCE_MODULE##*/}" in
desktop_entry.py|launcher_wrapper.py)
continue
;;
esac
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/"
done
install -m 0644 \
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
install -m 0644 \
"${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
"${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe"
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py"
printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
if ! "$PYTHON_BIN" -I -B -c \
'import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); from scripts.release_lib import load_config, stage_runtime_payload; stage_runtime_payload(Path(sys.argv[2]), load_config(Path(sys.argv[3])), source_root=Path(sys.argv[4]))' \
"$SOURCE_ROOT" "$STAGING_RELEASE" "${SOURCE_ROOT}/release/config.json" "$SOURCE_ROOT"
then
printf '%s\n' "The launcher runtime payload could not be staged." >&2
if [[ -d "$STAGING_RELEASE" && ! -L "$STAGING_RELEASE" ]]
then
rm -r -- "$STAGING_RELEASE"
fi
exit 1
fi
printf '{\n "channel": "%s",\n "config_schema": 1,\n "minimum_config_schema": 1,\n "schema": 1,\n "tag": "v%s",\n "version": "%s"\n}\n' \
"$RELEASE_CHANNEL" "$VERSION" "$VERSION" > "${STAGING_RELEASE}/release.json"
chmod 0644 "${STAGING_RELEASE}/release.json"
if ! "$PYTHON_BIN" -I -B -c \
@@ -261,7 +309,7 @@ CURRENT_TARGET=""
if [[ -L "${APP_ROOT}/current" ]]
then
CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")"
if [[ ! "$CURRENT_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ ]]
if [[ ! "$CURRENT_TARGET" =~ ^releases/${SEMVER_PATTERN}$ ]]
then
printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2
exit 1
@@ -279,7 +327,7 @@ fi
if [[ -L "${APP_ROOT}/previous" ]]
then
PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")"
if [[ ! "$PREVIOUS_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ \
if [[ ! "$PREVIOUS_TARGET" =~ ^releases/${SEMVER_PATTERN}$ \
|| ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]]
then
printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2
@@ -300,7 +348,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
"$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
"${SOURCE_ROOT}/packaging/pristontale-eu" \
"$APP_ROOT" \
"$INSTALL_DIRECTORY" \
@@ -308,7 +356,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
install -m 0644 \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"$ICON_PATH"
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
"$PYTHON_BIN" "${PACKAGE_SOURCE_ROOT}/ptlinux/desktop_entry.py" \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${BIN_HOME}/pristontale-eu" \
"$ICON_PATH" \
@@ -325,6 +373,29 @@ then
fi
printf '%s\n' "Launcher files installed."
CONTAINER_HOST_READY=1
if (( INSTALL_DEPENDENCIES == 1 ))
then
printf '%s\n' "Checking Steam Runtime sandbox permissions."
if ! "${BIN_HOME}/pristontale-eu" container-host --repair
then
CONTAINER_HOST_READY=0
fi
else
printf '%s\n' "Steam Runtime sandbox repair skipped with host dependencies."
fi
RUNTIME_BOOTSTRAP_READY=1
if (( RUN_RUNTIME_BOOTSTRAP == 1 ))
then
if ! "${BIN_HOME}/pristontale-eu" runtime-bootstrap
then
RUNTIME_BOOTSTRAP_READY=0
fi
else
printf '%s\n' "Steam Runtime helper preparation skipped."
fi
DOCTOR_READY=1
if (( RUN_DOCTOR == 1 ))
then
@@ -356,11 +427,28 @@ case ":${PATH:-}:" in
;;
esac
if (( CONTAINER_HOST_READY == 0 ))
then
printf '%s\n' \
"Launcher files were installed, but Bubblewrap cannot create the Steam Runtime sandbox." \
"Retry with: ${BIN_HOME}/pristontale-eu container-host --repair" >&2
exit 1
fi
if (( RUNTIME_BOOTSTRAP_READY == 0 ))
then
printf '%s\n' \
"Launcher files were installed, but the Steam Runtime helper could not be prepared." \
"Retry with: ${BIN_HOME}/pristontale-eu runtime-bootstrap" >&2
exit 1
fi
if (( DOCTOR_READY == 0 ))
then
printf '%s\n' \
"Launcher files were installed, but this host is not ready to run the game." \
"Resolve the diagnostics above, then run the launcher command again." >&2
"The exact cause is listed under 'Blocking diagnostics' above; optional tools do not block launch." \
"Re-run the check with: ${BIN_HOME}/pristontale-eu doctor" >&2
exit 1
fi

31
packaging/install-dependencies.sh Executable file → Normal file
View File

@@ -79,6 +79,12 @@ ptlinux_resolve_dependency_packages()
debian:openssl)
candidates=(openssl)
;;
debian:bubblewrap)
candidates=(bubblewrap)
;;
debian:apparmor)
candidates=(apparmor)
;;
fedora:python)
candidates=(python3)
;;
@@ -100,6 +106,9 @@ ptlinux_resolve_dependency_packages()
fedora:openssl)
candidates=(openssl)
;;
fedora:bubblewrap)
candidates=(bubblewrap)
;;
arch:python)
candidates=(python)
;;
@@ -121,6 +130,9 @@ ptlinux_resolve_dependency_packages()
arch:openssl)
candidates=(openssl)
;;
arch:bubblewrap)
candidates=(bubblewrap)
;;
arch:umu)
candidates=(umu-launcher)
;;
@@ -145,6 +157,9 @@ ptlinux_resolve_dependency_packages()
suse:openssl)
candidates=(openssl)
;;
suse:bubblewrap)
candidates=(bubblewrap)
;;
*)
printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2
return 1
@@ -239,6 +254,18 @@ ptlinux_detect_missing_dependencies()
then
PTLINUX_MISSING_DEPENDENCIES+=(openssl)
fi
if [[ ! -x /usr/bin/bwrap ]]
then
PTLINUX_MISSING_DEPENDENCIES+=(bubblewrap)
fi
if [[ "$family" == "debian" \
&& ( "${PTLINUX_DISTRIBUTION_ID,,}" == "ubuntu" \
|| " ${PTLINUX_DISTRIBUTION_LIKE,,} " == *" ubuntu "* ) \
&& -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns \
&& ! -x /usr/sbin/apparmor_parser ]]
then
PTLINUX_MISSING_DEPENDENCIES+=(apparmor)
fi
if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
then
PTLINUX_MISSING_DEPENDENCIES+=(umu)
@@ -353,7 +380,9 @@ ptlinux_install_host_dependencies()
ptlinux_detect_missing_dependencies "$family"
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
then
printf '%s\n' "All required host dependencies are already available."
printf '%s\n' \
"All automatically managed host packages are already available." \
"Final graphics and runtime diagnostics run after launcher installation."
return 0
fi
if [[ "$family" == "unknown" ]]

View File

@@ -12,17 +12,20 @@ export PTLINUX_APP_HOME="${app_home}"
release_root=""
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
do
if [ ! -d "${candidate}/ptlinux" ]
# Installed releases already pass an isolated import self-check before their
# activation link is changed. Keep only a cheap corruption/fallback check on
# every boot instead of starting Python twice and importing the full CLI twice.
if [ ! -r "${candidate}/ptlinux/__init__.py" ] || \
[ ! -r "${candidate}/ptlinux/__main__.py" ] || \
[ ! -r "${candidate}/ptlinux/cli.py" ] || \
[ ! -r "${candidate}/ptdiag/__init__.py" ] || \
[ ! -r "${candidate}/ptdiag/assbin.py" ] || \
[ ! -r "${candidate}/ptdiag/logs.py" ]
then
continue
fi
if "$python_bin" -I -B -c \
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
"${candidate}" >/dev/null 2>&1
then
release_root="${candidate}"
break
fi
release_root="${candidate}"
break
done
if [ -z "${release_root}" ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
"""Run the retained Proton 7 script safely on Python 3.14.
Proton 7 predates Python's tar extraction-filter API. The known-good v41
runtime used this compatibility shim; keeping it in the harness makes the
exact launch recipe inspectable and reproducible.
"""
from __future__ import annotations
from pathlib import Path
import runpy
import sys
import tarfile
from typing import Sequence
def main(argv: Sequence[str] | None = None) -> None:
arguments = list(sys.argv[1:] if argv is None else argv)
if not arguments:
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
proton = Path(arguments[0]).expanduser().resolve(strict=True)
proton_directory = str(proton.parent)
if proton_directory not in sys.path:
sys.path.insert(0, proton_directory)
if hasattr(tarfile, "fully_trusted_filter"):
tarfile.TarFile.extraction_filter = staticmethod(tarfile.fully_trusted_filter)
sys.argv = [str(proton), *arguments[1:]]
runpy.run_path(str(proton), run_name="__main__")
if __name__ == "__main__":
main()

View File

@@ -1,4 +0,0 @@
from .cli import main
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,243 +0,0 @@
"""Cross-distribution dependency and host diagnostics."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from importlib.util import find_spec
import os
from pathlib import Path
import platform
import shutil
from typing import Mapping
from .launcher import find_wine_tools
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
)
@dataclass(frozen=True)
class ToolStatus:
name: str
path: str | None
required: bool
purpose: str
@dataclass(frozen=True)
class DoctorReport:
ok: bool
distribution: str
version: str
architecture: str
display_backend: str
tools: list[ToolStatus]
package_hint: str | None
warnings: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
env = os.environ if environment is None else environment
os_release = _read_os_release()
distribution = os_release.get("ID", "unknown")
version = os_release.get("VERSION_ID", "unknown")
wine_tools = find_wine_tools()
tools = [
ToolStatus(
"python3",
shutil.which("python3"),
True,
"native installer and command-line interface",
),
ToolStatus(
"python-tk",
_python_tk_path(),
True,
"native dashboard",
),
ToolStatus(
"wine",
str(wine_tools.wine) if wine_tools else None,
True,
"legacy updater support and game compatibility utilities",
),
ToolStatus(
"winetricks",
str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"),
True,
"native D3D helper DLLs for the Proton game prefix",
),
ToolStatus(
"gamescope",
shutil.which("gamescope"),
False,
"optional manual game-container diagnostics",
),
ToolStatus(
"cabextract",
shutil.which("cabextract"),
True,
"Microsoft runtime extraction through Winetricks",
),
ToolStatus(
"openssl",
shutil.which("openssl"),
True,
"signed native-launcher update verification",
),
ToolStatus(
"umu-run",
shutil.which("umu-run"),
distribution in ARCH_FAMILY_DISTRIBUTIONS,
"current portable Proton runtime; automatic on Arch-family hosts",
),
ToolStatus(
"vulkaninfo",
shutil.which("vulkaninfo"),
False,
"Vulkan diagnostics",
),
]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
warnings.append("No graphical X11 or Wayland session is visible.")
if shutil.which("umu-run") is None and not _has_steam_proton7(env):
warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
)
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
distribution=distribution,
version=version,
architecture=platform.machine(),
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
tools=tools,
package_hint=_package_hint(distribution),
warnings=warnings,
)
def _read_os_release() -> dict[str, str]:
result: dict[str, str] = {}
try:
lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines()
except OSError:
return result
for line in lines:
if "=" not in line or line.startswith("#"):
continue
key, value = line.split("=", 1)
result[key] = value.strip().strip('"')
return result
def _python_tk_path() -> str | None:
try:
specification = find_spec("_tkinter")
except (ImportError, ValueError):
return None
if specification is None:
return None
return specification.origin or "available"
def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return (
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, "
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
)
if distribution in {"fedora", "nobara"}:
return (
"Install python3-tkinter, wine, winetricks, cabextract, openssl, "
"and the matching 32-bit Vulkan driver with DNF."
)
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return (
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return (
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper."
)
return None
def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
home_value = environment.get("HOME")
if not home_value:
return False
home = Path(home_value)
for steam_root in (
home / ".local/share/Steam",
home / ".steam/steam",
home / "snap/steam/common/.local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
):
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
return True
return False
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
if architecture.casefold() not in {"x86_64", "amd64"}:
return []
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return [
_optional_tool_status(
"lib32-vulkan",
"/usr/lib32/libvulkan.so.1",
"32-bit Vulkan runtime compatibility layer",
),
_optional_tool_status(
"lib32-gstreamer",
"/usr/lib32/gstreamer-1.0",
"32-bit gstreamer runtime compatibility modules",
),
]
return []
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
if distribution not in ARCH_FAMILY_DISTRIBUTIONS:
return []
if architecture.casefold() not in {"x86_64", "amd64"}:
return [
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
]
warnings: list[str] = []
if not _path_exists("/usr/lib32/libvulkan.so.1"):
warnings.append(
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
)
if not _path_exists("/usr/lib32/gstreamer-1.0"):
warnings.append(
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
"other media does not play. Missing media plugins are not evidence of an "
"authentication failure."
)
return warnings
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
if _path_exists(path):
return ToolStatus(name, path, required=False, purpose=purpose)
return ToolStatus(name, None, required=False, purpose=purpose)
def _path_exists(path: str) -> bool:
entry = Path(path)
return entry.is_file() or entry.is_dir()

View File

@@ -1,29 +0,0 @@
"""Native X11 command construction for the clean borderless game window."""
from __future__ import annotations
import sys
from typing import Sequence
from .settings import Settings
def build_gamebox_command(
game_command: Sequence[str],
settings: Settings,
) -> list[str]:
if not game_command:
raise ValueError("game command cannot be empty")
command = [
sys.executable,
"-m",
"ptlinux.gamebox_runtime",
"--window-title",
"PristonTale EU",
]
if settings.borderless:
command.append("--borderless")
if settings.suppress_unresponsive_warnings:
command.append("--suppress-wm-ping")
command.extend(("--", *[str(value) for value in game_command]))
return command

View File

@@ -1,496 +0,0 @@
"""Map and style the real Wine game window on the host X11 display."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import subprocess
import sys
import time
from collections.abc import Collection, Iterable, Sequence
from .desktop_identity import (
APPLICATION_NAME,
APPLICATION_WM_CLASS,
APPLICATION_WM_INSTANCE,
)
WINDOW_TITLE = APPLICATION_NAME
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
def _ignore_x11_error(_display: object, _event: object) -> int:
"""Keep a stale X11 window race from terminating the mapper process."""
return 0
def _matches_game_window_title(title: str, expected_title: str) -> bool:
"""Match the game title without accepting dialogs that merely mention it."""
candidate = title.strip().casefold()
expected = expected_title.strip().casefold()
if not candidate or not expected:
return False
return candidate == expected or candidate.startswith(f"{expected} ")
def _select_game_window(
windows: Iterable[tuple[int, str]],
expected_title: str,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
for window, title in windows:
if window in excluded_window_ids:
continue
if _matches_game_window_title(title, expected_title):
return window, title
return None
def _protocols_without(
protocols: Sequence[int],
blocked_protocol: int,
) -> tuple[int, ...]:
"""Remove only the selected atom while preserving every other protocol."""
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
class GameboxRuntimeError(RuntimeError):
"""Raised when the game command or X11 mapper cannot be started."""
class _MotifWmHints(ctypes.Structure):
_fields_ = [
("flags", ctypes.c_ulong),
("functions", ctypes.c_ulong),
("decorations", ctypes.c_ulong),
("input_mode", ctypes.c_long),
("status", ctypes.c_ulong),
]
class _XClassHint(ctypes.Structure):
_fields_ = [
("res_name", ctypes.c_char_p),
("res_class", ctypes.c_char_p),
]
class _X11:
def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
try:
self.library = ctypes.CDLL(library_path)
except OSError as exc:
raise GameboxRuntimeError("the X11 client library is unavailable") from exc
self._configure_signatures()
self._error_handler = _X_ERROR_HANDLER(_ignore_x11_error) # type: ignore[arg-type]
self._previous_error_handler = self.library.XSetErrorHandler(
ctypes.cast(self._error_handler, ctypes.c_void_p),
)
self.display = self.library.XOpenDisplay(None)
if not self.display:
self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display")
def close(self) -> None:
try:
if self.display:
self.library.XCloseDisplay(self.display)
self.display = None
finally:
self._restore_error_handler()
def root_window_ids(self) -> set[int]:
return set(self._root_windows())
def find_first_window(
self,
title_fragment: str,
*,
excluded_window_ids: Collection[int],
) -> tuple[int, str] | None:
windows: list[tuple[int, str]] = []
for window in self._root_windows():
title = self._window_title(window)
if title is not None:
windows.append((window, title))
return _select_game_window(windows, title_fragment, excluded_window_ids)
def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display)
root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)()
child_count = ctypes.c_uint()
status = self.library.XQueryTree(
self.display,
root,
ctypes.byref(root_return),
ctypes.byref(parent_return),
ctypes.byref(children),
ctypes.byref(child_count),
)
if status == 0:
return []
try:
return [int(children[index]) for index in range(child_count.value)]
finally:
if children:
self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
def map_window(self, window: int, *, borderless: bool) -> None:
if borderless:
self._remove_decorations(window)
self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display)
def set_window_identity(
self,
window: int,
*,
resource_name: str = APPLICATION_WM_INSTANCE,
resource_class: str = APPLICATION_WM_CLASS,
) -> None:
"""Replace Proton's generic steamapp identity before taskbar matching."""
for label, value in (
("resource name", resource_name),
("resource class", resource_class),
):
if not value or "\x00" in value:
raise GameboxRuntimeError(f"invalid X11 {label}")
hints = _XClassHint(
res_name=resource_name.encode("utf-8"),
res_class=resource_class.encode("utf-8"),
)
if self.library.XSetClassHint(
self.display,
window,
ctypes.byref(hints),
) == 0:
raise GameboxRuntimeError("could not set the game window identity")
self.library.XSync(self.display, 0)
def suppress_wm_ping(self, window: int) -> bool:
"""Stop the WM hang prompt for one window, preserving its other protocols."""
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
if ping_atom == 0:
return False
protocols = self._wm_protocols(window)
if protocols is None:
return False
filtered_protocols = _protocols_without(protocols, ping_atom)
if filtered_protocols == protocols:
return True
if filtered_protocols:
protocol_values = (ctypes.c_ulong * len(filtered_protocols))(
*filtered_protocols
)
status = self.library.XSetWMProtocols(
self.display,
window,
protocol_values,
len(filtered_protocols),
)
else:
status = self.library.XSetWMProtocols(
self.display,
window,
ctypes.POINTER(ctypes.c_ulong)(),
0,
)
if status == 0:
return False
self.library.XSync(self.display, 0)
return self._wm_protocols(window) == filtered_protocols
def _wm_protocols(self, window: int) -> tuple[int, ...] | None:
protocols = ctypes.POINTER(ctypes.c_ulong)()
protocol_count = ctypes.c_int()
status = self.library.XGetWMProtocols(
self.display,
window,
ctypes.byref(protocols),
ctypes.byref(protocol_count),
)
if status == 0:
return None
try:
return tuple(
int(protocols[index]) for index in range(protocol_count.value)
)
finally:
if protocols:
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
if atom == 0:
return
hints = _MotifWmHints(
flags=1 << 1,
functions=0,
decorations=0,
input_mode=0,
status=0,
)
self.library.XChangeProperty(
self.display,
window,
atom,
atom,
32,
0,
ctypes.cast(ctypes.byref(hints), ctypes.POINTER(ctypes.c_ubyte)),
5,
)
def _window_title(self, window: int) -> str | None:
value = ctypes.c_char_p()
if self.library.XFetchName(self.display, window, ctypes.byref(value)) == 0:
return None
try:
if value.value is None:
return None
return value.value.decode("utf-8", errors="replace")
finally:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def _restore_error_handler(self) -> None:
if self._error_handler is None:
return
self.library.XSetErrorHandler(self._previous_error_handler)
self._error_handler = None
def _configure_signatures(self) -> None:
self.library.XSetErrorHandler.argtypes = [ctypes.c_void_p]
self.library.XSetErrorHandler.restype = ctypes.c_void_p
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.library.XOpenDisplay.restype = ctypes.c_void_p
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
ctypes.POINTER(ctypes.c_uint),
]
self.library.XQueryTree.restype = ctypes.c_int
self.library.XFetchName.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_char_p),
]
self.library.XFetchName.restype = ctypes.c_int
self.library.XInternAtom.argtypes = [
ctypes.c_void_p,
ctypes.c_char_p,
ctypes.c_int,
]
self.library.XInternAtom.restype = ctypes.c_ulong
self.library.XChangeProperty.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_int,
ctypes.POINTER(ctypes.c_ubyte),
ctypes.c_int,
]
self.library.XChangeProperty.restype = ctypes.c_int
self.library.XSetClassHint.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(_XClassHint),
]
self.library.XSetClassHint.restype = ctypes.c_int
self.library.XGetWMProtocols.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
ctypes.POINTER(ctypes.c_int),
]
self.library.XGetWMProtocols.restype = ctypes.c_int
self.library.XSetWMProtocols.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.c_int,
]
self.library.XSetWMProtocols.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.library.XSync.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p]
self.library.XFlush.restype = ctypes.c_int
self.library.XFree.argtypes = [ctypes.c_void_p]
self.library.XFree.restype = ctypes.c_int
self.library.XCloseDisplay.argtypes = [ctypes.c_void_p]
self.library.XCloseDisplay.restype = ctypes.c_int
def _prepare_game_window(
mapper: _X11,
window: int,
*,
borderless: bool,
suppress_wm_ping: bool,
) -> None:
if suppress_wm_ping:
try:
ping_suppressed = mapper.suppress_wm_ping(window)
except Exception as exc:
print(
f"Warning: could not suppress game-window hang warnings ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
else:
if ping_suppressed:
print(
"Game-window hang warnings suppressed for this launch.",
file=sys.stderr,
flush=True,
)
else:
print(
"Warning: could not verify game-window hang-warning "
"suppression; continuing normally",
file=sys.stderr,
flush=True,
)
mapper.map_window(window, borderless=borderless)
def _apply_game_window_identity(mapper: _X11, window: int) -> None:
try:
mapper.set_window_identity(window)
except Exception as exc:
print(
f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
def run_game_window(
command: Sequence[str],
window_title: str = WINDOW_TITLE,
*,
borderless: bool = False,
suppress_wm_ping: bool = False,
) -> int:
if not command:
raise GameboxRuntimeError("game command cannot be empty")
mapper: _X11 | None = None
existing_window_ids: set[int] = set()
try:
created_mapper = _X11()
existing_window_ids = created_mapper.root_window_ids()
mapper = created_mapper
except GameboxRuntimeError as error:
print(
f"Warning: {error}; continuing without X11 window styling",
file=sys.stderr,
flush=True,
)
process = subprocess.Popen([str(value) for value in command])
if mapper is None:
print(
f"Game window ready: unmanaged {window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
game_window: int | None = None
game_title: str | None = None
game_window_created_at: float | None = None
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
try:
while process.poll() is None and time.monotonic() < deadline:
now = time.monotonic()
if game_window is None:
found = mapper.find_first_window(
window_title,
excluded_window_ids=existing_window_ids,
)
if found is not None:
window, title = found
game_window = window
game_title = title
game_window_created_at = now
_apply_game_window_identity(mapper, window)
print(
f"Game window created; preparing it: {title}",
file=sys.stderr,
flush=True,
)
if (
game_window is not None
and game_window_created_at is not None
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
):
_prepare_game_window(
mapper,
game_window,
borderless=borderless,
suppress_wm_ping=suppress_wm_ping,
)
print(
f"Game window ready: {game_title or window_title}",
file=sys.stderr,
flush=True,
)
return int(process.wait())
time.sleep(0.1)
if process.poll() is None:
print(
f"Warning: no new game window matching {window_title!r} appeared",
file=sys.stderr,
flush=True,
)
if game_window is not None:
_prepare_game_window(
mapper,
game_window,
borderless=borderless,
suppress_wm_ping=suppress_wm_ping,
)
return int(process.wait())
finally:
mapper.close()
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--window-title", default=WINDOW_TITLE)
parser.add_argument("--borderless", action="store_true")
parser.add_argument("--suppress-wm-ping", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv)
command = arguments.command
if command and command[0] == "--":
command = command[1:]
try:
return run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
suppress_wm_ping=arguments.suppress_wm_ping,
)
except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,54 @@
Gamescope compatibility binary
================================
gamescope-3.16.20-ubuntu26.04-amd64 is an isolated compatibility build for
Ubuntu 26.04 amd64. The launcher uses it only when the installed system
Gamescope reports version 3.16.20 and the binary passes its embedded SHA-256
check. It never replaces /usr/games/gamescope.
Source package:
Ubuntu gamescope 3.16.20+ds-1
gamescope_3.16.20+ds.orig.tar.xz
SHA-256 a27984fdab2bbfe353efc772de214ae88cccab07c4507747ed4b7f063b2faa0e
gamescope_3.16.20+ds-1.debian.tar.xz
SHA-256 ae6e9bac39b576e7d9c15912a713796049a29756f6858364ef49bd02197cb153
Build:
ubuntu:26.04 container
apt-get build-dep gamescope
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage --build=binary --no-sign -jauto
The source changes are:
gamescope-wayland-letterbox-backing.patch
Backports upstream commit 66450376038864700f9b4721343b4d15b29c4198
("Use a backing surface for letter-boxing"). It fixes direct Wayland
output when a unity-scaled game surface has a nonzero letterbox offset.
gamescope-wayland-libdecor-input-scope.patch
Keeps libdecor titlebar and resize-border button events out of the nested
application while preserving complete button sequences that begin on the
game surface. This prevents a decoration operation from leaving the game
with a permanently held mouse button. It addresses the behavior tracked
at https://github.com/ValveSoftware/gamescope/issues/2299.
gamescope-lazy-output-images.patch
Backports upstream commit e383171ff1c0f7b5b37f05f2bb67f7c5282b087f
("rendervulkan: make the shared output images on first use"). Direct
Wayland frames no longer allocate six output-sized Vulkan images at every
intermediate host-window size.
gamescope-wayland-live-resize.patch
Skips the remaining device-idle wait and transparent fallback-image rebuild
when direct Wayland presentation has no live output images. Full-composition
frames still allocate correctly sized images on demand.
gamescope-keep-nested-windows-visible.patch
Honors PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE by rejecting the game's
login-transition iconify request. The outer desktop window retains its
normal size and visibility while host minimize and focus remain untouched.
Binary SHA-256:
3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639
Licensing and copyright notices are in gamescope-compat.copyright.

View File

@@ -0,0 +1,343 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://github.com/ValveSoftware/gamescope
Upstream-Name: gamescope
Upstream-Contact: Pierre-Loup A. Griffais <https://www.valvesoftware.com/en/contact?contact-person=Pierre-Loup%20Griffais>
Comment: Build depends on libopenvr-dev which is in contrib
Files-Excluded:
# These files were excluded in the patch 0005_add_reshade_696b14c.patch
src/reshade/.gitignore
src/reshade/.gitmodules
src/reshade/res/main_icon.ico
src/reshade/res/main_icon_small.png
src/reshade/res/shaders/imgui_ps.spv
src/reshade/res/shaders/imgui_ps_hdr.spv
src/reshade/res/shaders/imgui_vs.spv
src/reshade/tools/7za.exe
src/reshade/examples
src/reshade/setup/Pages/Resources
# reshade gitmodules
src/reshade/deps/imgui
src/reshade/deps/minhook
src/reshade/deps/stb
src/reshade/deps/gl3w
src/reshade/deps/utfcpp
src/reshade/deps/spirv
src/reshade/deps/vma
src/reshade/deps/vulkan
src/reshade/deps/fpng
src/reshade/deps/d3d12
src/reshade/deps/openxr
Files:
*
Copyright:
2013-2023, Valve Corporation
2022, NVIDIA CORPORATION
License: BSD-2-Clause
Files: thirdparty/sol/*
Copyright: 2013-2020, Rapptz, ThePhD and contributors
License: Expat
Files: scripts/00-gamescope/common/inspect.lua
Copyright: 2022, Enrique García Cota
License: Expat
Files: src/color_helpers.h
Copyright: 2021-2022 Collabora, Ltd
License: Expat
Files: src/messagey.h
Copyright: 1997-2022, Sam Lantinga <slouken@libsdl.org>
License: Zlib
Files:
protocol/color-management-v1.xml
Copyright:
2019 Sebastian Wick
2019 Erwin Burema
2020 AMD
2020-2024 Collabora, Ltd.
2024 Xaver Hugl
2022-2025 Red Hat, Inc.
License: Expat
Files:
protocol/frog-color-management-v1.xml
Copyright:
2023, Joshua Ashton for Valve Software
2023, Xaver Hugl
License: Expat
Files:
protocol/gamescope-action-binding.xml
Copyright:
2024 Valve Corporation
License: Expat
Files:
protocol/gamescope-control.xml
protocol/gamescope-private.xml
Copyright:
2023 Valve Corporation
License: Expat
Files:
protocol/gamescope-input-method.xml
Copyright:
2008-2011 Kristian Høgsberg
2010-2011 Intel Corporation
2012-2013 Collabora, Ltd.
2012, 2013 Intel Corporation
2015, 2016 Jan Arne Petersen
2017, 2018 Red Hat, Inc.
2018 Purism SPC
2021 Valve Corporation
License: Expat
Files:
protocol/gamescope-pipewire.xml
protocol/gamescope-xwayland.xml
Copyright:
2021 Valve Corporation
License: Expat
Files:
protocol/gamescope-reshade.xml
Copyright:
2024, Wayne Heaney
License: Expat
Files:
protocol/gamescope-swapchain.xml
Copyright:
2023, Joshua Ashton for Valve Software
License: Expat
Files:
protocol/wlr-layer-shell-unstable-v1.xml
Copyright:
2017, Drew DeVault
License: HPND-sell-variant or NTP~disclaimer
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
Files:
protocol/xdg-toplevel-icon-v1.xml
Copyright:
2023-2024, Matthias Klumpp
2024, David Edmundson
License: Expat
Files:
src/shaders/NVIDIAImageScaling/*
Copyright:
2022 NVIDIA CORPORATION & AFFILIATES
License: Expat
Files:
src/shaders/ffx_*.h
Copyright:
2021 Advanced Micro Devices, Inc.
License: Expat
Files:
src/gpuvis_trace_utils.h
Copyright:
Michael Sartain
License: public-domain-dual
This software is dual-licensed to the public domain and under the following
license: you are granted a perpetual, irrevocable license to copy, modify,
publish, and distribute this file as you see fit.
Files:
src/modegen.cpp
Copyright:
2005-2006 Luc Verhaegen
License: Expat
Files:
src/shaders/heatmap.h
Copyright:
Microsoft Corporation
License: Expat
Files:
src/steamcompmgr.cpp
Copyright:
2003 Keith Packard
Matthew Hawn
License: HPND-sell-variant
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
the above copyright notice appear in all copies and that both that
copyright notice and this permission notice appear in supporting
documentation, and that the name of Keith Packard not be used in
advertising or publicity pertaining to distribution of the software without
specific, written prior permission. Keith Packard makes no
representations about the suitability of this software for any purpose. It
is provided "as is" without express or implied warranty.
.
KEITH PACKARD DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO
EVENT SHALL KEITH PACKARD BE LIABLE FOR ANY SPECIAL, INDIRECT OR
CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
Files:
debian/*
Copyright:
2022-2024 Stephan Lachnit <stephanlachnit@debian.org>
2023-2024 Safir Secerovic <sapphire@linux.org.ba>
2025 Ilya Orlov <ilyaorlov124@gmail.com>,
License: BSD-2-Clause
Files:
src/reshade/*
Copyright:
2014-2024 Patrick Mours. All rights reserved.
License: BSD-3-Clause
Files:
src/reshade/deps/d3d911on12/*
Copyright:
Microsoft Corporation.
License: Expat
Files:
src/reshade/deps/vulkan_layer/vk_layer_dispatch_table.h
Copyright:
2015-2023, Google Inc.
2015-2023, LunarG, Inc.
2015-2023, The Khronos Group Inc.
2015-2023, Valve Corporation
License: Apache-2.0
Files: src/reshade/source/imgui_code_editor.cpp
Copyright:
2017, BalazsJako
2018, Patrick Mours
License: Expat
License: BSD-2-Clause
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
License: Expat
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
License: BSD-3-Clause
Copyright (c) The Regents of the University of California.
All rights reserved.
.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the University nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
.
THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
License: Zlib
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
.
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
License: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
.
http://www.apache.org/licenses/LICENSE-2.0
.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
.
On Debian systems, the full text of the Apache Software License version 2 can
be found in the file `/usr/share/common-licenses/Apache-2.0'.

View File

@@ -0,0 +1,34 @@
From: PT Linux Launcher maintainers
Subject: Keep the nested game window visible during its login transition
PristonTale replaces its startup presentation while entering the login screen
and asks the nested X11 window manager to mark the game window iconic. Gamescope
3.16.20 acknowledges that request by publishing IconicState even though the
surface stays mapped. Wine then hides or resizes the client, which also changes
the outer Gamescope presentation.
When the launcher opts in through PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE, keep
the nested client in NormalState. This is scoped to the launcher environment;
Gamescope retains its upstream behavior for every other invocation. Desktop
minimize and focus operate on the outer Wayland window and remain unaffected.
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -5361,8 +5361,17 @@ static void
handle_wm_change_state(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, XClientMessageEvent *ev)
{
long state = ev->data.l[0];
+ static const bool bKeepNestedWindowsVisible = env_to_bool(
+ getenv( "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" ) );
if (state == ICCCM_ICONIC_STATE) {
+ if ( bKeepNestedWindowsVisible )
+ {
+ xwm_log.debugf("Ignoring WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id);
+ set_wm_state( ctx, w->xwayland().id, ICCCM_NORMAL_STATE );
+ return;
+ }
+
xwm_log.debugf("Faking WM_CHANGE_STATE to ICONIC for window 0x%lx", w->xwayland().id);
set_wm_state( ctx, w->xwayland().id, ICCCM_ICONIC_STATE );
} else {

View File

@@ -0,0 +1,80 @@
From: Matthew Schwartz <matthew.schwartz@linux.dev>
Subject: Lazily allocate shared Vulkan output images
Origin: upstream, commit e383171ff1c0f7b5b37f05f2bb67f7c5282b087f
Backport the upstream lazy-allocation change to Gamescope 3.16.20. Native
Wayland presentation normally sends the game's DMA-BUF directly to the host
compositor, so allocating six output-sized Vulkan images at startup and after
every host-window configure is unnecessary. Allocate those images only if a
frame actually needs full Vulkan composition.
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -3345,6 +3345,31 @@ static bool vulkan_make_output_images( VulkanOutput_t *pOutput )
return true;
}
+// Without a swapchain the shared images are ours to make, so they wait for their first use.
+static bool vulkan_defer_output_images()
+{
+ return !GetBackend()->UsesVulkanSwapchain();
+}
+
+static bool vulkan_ensure_output_images()
+{
+ if ( !vulkan_defer_output_images() )
+ return true;
+
+ VulkanOutput_t *pOutput = &g_output;
+ if ( !pOutput->outputImages.empty() && pOutput->outputImages[0] )
+ return true;
+
+ vk_log.debugf( "Making the shared output images on first use" );
+ if ( vulkan_make_output_images( pOutput ) )
+ return true;
+
+ // A half-made set would pass the check above, so the next use tries again from nothing.
+ pOutput->outputImages.clear();
+ pOutput->outputImagesPartialOverlay.clear();
+ return false;
+}
+
bool vulkan_remake_output_images()
{
VulkanOutput_t *pOutput = &g_output;
@@ -3355,6 +3380,14 @@ bool vulkan_remake_output_images()
for (auto& pScreenshotImage : pOutput->pScreenshotImages)
pScreenshotImage = nullptr;
+ if ( vulkan_defer_output_images() )
+ {
+ pOutput->outputImages.clear();
+ pOutput->outputImagesPartialOverlay.clear();
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ return true;
+ }
+
bool bRet = vulkan_make_output_images( pOutput );
assert( bRet );
return bRet;
@@ -3433,7 +3466,9 @@ bool vulkan_make_output()
return false;
}
- if ( !vulkan_make_output_images( pOutput ) )
+ if ( vulkan_defer_output_images() )
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ else if ( !vulkan_make_output_images( pOutput ) )
return false;
}
@@ -3980,6 +4015,9 @@ std::optional<uint64_t> vulkan_composite( struct FrameInfo_t *frameInfo, gamesco
g_reshadeManager.clear();
}
+ if ( !pOutputOverride && !vulkan_ensure_output_images() )
+ return std::nullopt;
+
gamescope::Rc<CVulkanTexture> compositeImage;
if ( pOutputOverride )
compositeImage = pOutputOverride;

View File

@@ -0,0 +1,21 @@
From: PT Linux Launcher maintainers
Subject: Use a backing surface for letter-boxing
Backport upstream commit 66450376038864700f9b4721343b4d15b29c4198 so
native Wayland presentation covers the complete configured output when the
game layer is unity-scaled with a non-zero letterbox offset.
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -1081,7 +1081,10 @@ namespace gamescope
bool bNeedsBacking = true;
if ( pFrameInfo->layerCount >= 1 )
{
- if ( pFrameInfo->layers[0].isScreenSize() && !pFrameInfo->layers[0].hasAlpha() )
+ if ( pFrameInfo->layers[0].isScreenSize() &&
+ close_enough( pFrameInfo->layers[0].offset.x, 0.0f ) &&
+ close_enough( pFrameInfo->layers[0].offset.y, 0.0f ) &&
+ !pFrameInfo->layers[0].hasAlpha() )
bNeedsBacking = false;
}

View File

@@ -0,0 +1,64 @@
From: PT Linux Launcher maintainers
Subject: Keep client-side decoration clicks out of nested applications
Bug: https://github.com/ValveSoftware/gamescope/issues/2299
Forwarded: https://github.com/ValveSoftware/gamescope/issues/2299
On compositors without xdg-decoration support, libdecor creates client-side
surfaces owned by the Gamescope Wayland connection. Pointer button callbacks
for those titlebar and resize surfaces consequently also reach Gamescope's
input thread. Forwarding a decoration press to the nested application can
leave its button permanently held because libdecor consumes the matching
release while performing a move, resize, or maximize operation.
Only begin nested button sequences while the pointer is over Gamescope's
actual toplevel surface. Track those presses so their releases are delivered
even if the pointer or keyboard focus changes during a game-originated drag.
--- a/src/Backends/WaylandBackend.cpp
+++ b/src/Backends/WaylandBackend.cpp
@@ -562,6 +562,7 @@
std::atomic<std::shared_ptr<zwp_relative_pointer_v1>> m_pRelativePointer = nullptr;
std::unordered_set<uint32_t> m_uScancodesHeld;
+ std::unordered_set<uint32_t> m_uMouseButtonsHeld;
void Wayland_Registry_Global( wl_registry *pRegistry, uint32_t uName, const char *pInterface, uint32_t uVersion );
static const wl_registry_listener s_RegistryListener;
@@ -3091,13 +3092,33 @@
}
void CWaylandInputThread::Wayland_Pointer_Button( wl_pointer *pPointer, uint32_t uSerial, uint32_t uTime, uint32_t uButton, uint32_t uState )
{
- // Don't do any motion/movement stuff if we don't have kb focus
- if ( !cv_wayland_mouse_warp_without_keyboard_focus && !m_bKeyboardEntered )
- return;
+ const bool bPressed = uState == WL_POINTER_BUTTON_STATE_PRESSED;
+ const bool bWasPressed = m_uMouseButtonsHeld.contains( uButton );
+
+ if ( bPressed )
+ {
+ // libdecor's client-side decorations share our Wayland seat, but
+ // their button events must not be sent to the nested application.
+ if ( !m_bMouseEntered || bWasPressed )
+ return;
+
+ // Don't start an application click without keyboard focus.
+ if ( !cv_wayland_mouse_warp_without_keyboard_focus && !m_bKeyboardEntered )
+ return;
+ }
+ else if ( !bWasPressed )
+ {
+ return;
+ }
wlserver_lock();
- wlserver_mousebutton( uButton, uState == WL_POINTER_BUTTON_STATE_PRESSED, ++m_uFakeTimestamp );
+ wlserver_mousebutton( uButton, bPressed, ++m_uFakeTimestamp );
wlserver_unlock();
+
+ if ( bPressed )
+ m_uMouseButtonsHeld.emplace( uButton );
+ else
+ m_uMouseButtonsHeld.erase( uButton );
}
void CWaylandInputThread::Wayland_Pointer_Axis( wl_pointer *pPointer, uint32_t uTime, uint32_t uAxis, wl_fixed_t fValue )
{

View File

@@ -0,0 +1,50 @@
From: PT Linux Launcher maintainers
Subject: Avoid idle waits for direct Wayland resize configures
The lazy-output-images backport leaves no shared output images allocated while
Gamescope is presenting the game directly as Wayland planes. A host-window
configure nevertheless calls vulkan_remake_output_images(), whose unconditional
device-idle wait and output-sized blank-texture recreation stall the compositor
thread for every intermediate drag size.
Wait only when an output or screenshot image actually exists. The transparent
fallback texture is unused by the non-session Wayland backend, so retain it
across Wayland size changes; session backends preserve the existing recreation
behavior. If a frame later requires full composition, the lazy allocation path
creates correctly sized output images before rendering it.
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -3374,7 +3374,21 @@ static bool vulkan_ensure_output_images()
bool vulkan_remake_output_images()
{
VulkanOutput_t *pOutput = &g_output;
- g_device.waitIdle();
+ bool bHasLiveImages =
+ !pOutput->outputImages.empty() && pOutput->outputImages[0];
+ if ( !bHasLiveImages )
+ {
+ for ( const auto &pScreenshotImage : pOutput->pScreenshotImages )
+ {
+ if ( pScreenshotImage )
+ {
+ bHasLiveImages = true;
+ break;
+ }
+ }
+ }
+ if ( bHasLiveImages )
+ g_device.waitIdle();
pOutput->nOutImage = 0;
@@ -3386,7 +3400,8 @@ bool vulkan_remake_output_images()
{
pOutput->outputImages.clear();
pOutput->outputImagesPartialOverlay.clear();
- pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
+ if ( GetBackend()->IsSessionBased() || !pOutput->temporaryHackyBlankImage )
+ pOutput->temporaryHackyBlankImage = vulkan_create_debug_blank_texture();
return true;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "PTEU-Linux-Launcher"
version = "0.6.7"
version = "0.9.11"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11"
dependencies = []
@@ -8,3 +12,27 @@ dependencies = []
[project.scripts]
ptdiag = "ptdiag.cli:main"
pristontale-eu = "ptlinux.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
include-package-data = true
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
ptlinux = ["launcher-update-public.pem", "native/*"]
[tool.ruff]
target-version = "py311"
line-length = 100
extend-exclude = [
".artifacts",
".venv",
"candidates",
"dist",
"results",
]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]

216
release/README.md Normal file
View File

@@ -0,0 +1,216 @@
# Manual Gitea launcher releases
Manual publication mode is currently active. Build a complete local package with:
```text
./scripts/release.sh stable 1.8.0
./scripts/release.sh prerelease 1.9.0-beta.1
```
The command creates `dist/v<VERSION>/`, validates the channel/version pair,
builds and previews release notes, runs the configured preflight, creates a
deterministic archive, signs the manifest, and verifies the complete result. It
changes the requested version only inside the staged package, so the source
checkout is unchanged. It does not commit, tag, push, create a Gitea release,
or upload anything.
The default archive layout is flat: extracting the tar directly produces
`ptlinux/`, `ptdiag/`, and `release.json`. Versions `0.9.10` and `0.9.11` form a
short compatibility window and use the legacy enclosing directory. That lets
deployed `0.9.8` launchers install either release automatically, even if they
missed `0.9.10`. The updater inside both releases accepts both layouts, so
releases after `0.9.11` return to the flat layout.
Use `--description` for short Markdown or `--description-file` for a prepared
Markdown document. Generated commit notes are appended by default. Run
`./scripts/release.sh --help` for dry-run, editor, changelog-only, signing-key,
and test options.
After a successful build, create a new release in Gitea with tag `v<VERSION>`.
Leave **Pre-release** clear for `stable`; select it for `prerelease`. Use
`dist/v<VERSION>/release-notes.md` as the description and upload exactly these
five files from that version directory:
- `PristonTale-EU-<VERSION>.tar.gz`
- `PristonTale-EU-<VERSION>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
Uploading a release does not by itself move the launcher's channel pointer.
Upload and save all five assets before publishing the channel pointer. The
signed archive URL includes its SHA-256 as a query parameter so Cloudflare and
other CDNs cannot reuse an empty response cached before the attachment existed.
After the Gitea release exists and each attachment has a nonzero size, publish
the matching files generated under
`dist/v<VERSION>/channel/` to the repository:
```text
dist/v<VERSION>/channel/stable.json -> updates/stable.json
dist/v<VERSION>/channel/stable.json.sig -> updates/stable.json.sig
dist/v<VERSION>/channel/prerelease.json -> updates/prerelease.json
dist/v<VERSION>/channel/prerelease.json.sig -> updates/prerelease.json.sig
```
Commit and push only the selected channel pair. Publishing a prerelease must not
change `updates/stable.json`.
Manual mode does not rebuild `updates/history.json`. The uploaded version can be
discovered through its channel pointer, but it will not appear as a remote
rollback candidate until signed history publication is run manually or the
automatic publisher is re-enabled.
The published `0.9.9` release is flat and therefore correctly asks deployed
`0.9.8` launchers for a manual update. Releases `0.9.10` and `0.9.11` use the
wrapped bridge layout and minimum updater `0.5.0`, so those launchers can install
either one automatically. Flat manifests after this compatibility window declare
`0.9.9` as their minimum updater version.
For direct artifact tooling without release notes or manual-upload guidance:
```text
PTLINUX_PYTHON=/path/to/python \
./scripts/build-release.sh 0.9.11 stable ./dist/v0.9.11
./scripts/verify-release.sh 0.9.11 stable ./dist/v0.9.11
```
The builder selects the runtime payload declared in `config.json`; it does not
archive the repository. It emits a deterministic versioned archive, checksum,
and external per-file manifest. When `PTLINUX_SIGNING_KEY` is set, it also
emits and immediately verifies `manifest.json.sig`.
`repository_url` is the public HTTPS base used in signed download links.
`allowed_git_remotes` is retained for the deferred automatic release validation;
it is not consulted by the current local packaging command.
## Deferred automatic publication
The automatic publisher and status publisher remain in the repository for a
later migration, but their push triggers are disabled. Both workflows are
manual-dispatch only, so creating a tag or Gitea release cannot race the manual
upload process. The remainder of this section applies only when automatic
publication is re-enabled.
## Repository and runner setup
Enable Gitea Actions for the instance and repository, then register an isolated
Docker-backed `act_runner` for trusted repository workflows. The runner image
must provide Git, Bash, curl, jq, Python 3.11 or newer, tar, gzip, coreutils
(`base64`, `realpath`, `sha256sum`, and `stat`), and OpenSSL. The release job
uses `actions/checkout@v4` and checks that the checked-out commit is the exact
commit referenced by the annotated tag.
Configure these protected Actions secrets:
- `RELEASE_TOKEN`: a dedicated token with only the repository release and
repository-content write permissions needed to create releases, upload
attachments, and update signed channel metadata.
- `MANIFEST_SIGNING_KEY`: the private key corresponding to
`ptlinux/launcher-update-public.pem`.
Secrets are available only to trusted tag and release-status workflows. Do not
add the release secrets to pull-request workflows. The signing key is written under
`RUNNER_TEMP` with mode `0600`, used for signing, and removed by an `always()`
cleanup step. Shell tracing is intentionally disabled around all secret-bearing
commands.
The current installed population verifies the deployed RSA/SHA-256 public key.
Release tooling can use OpenSSL Ed25519 signing, but changing the production
key or algorithm requires a staged key-rotation release first. Until that
migration is deployed, configure the matching existing RSA private key.
## Automatic publication behavior
The workflow validates the tag in CI, runs tests, builds and verifies every
artifact, creates a non-draft Gitea release with the derived prerelease flag,
and uploads these immutable assets separately:
- `PristonTale-EU-<version>.tar.gz`
- `PristonTale-EU-<version>.tar.gz.sha256`
- `manifest.json`
- `manifest.json.sig`
- `release-notes.md`
It re-queries the release and checks every attachment name and size before
updating metadata. A stable tag updates only `updates/stable.json` and its
signature. A beta or RC tag updates only `updates/prerelease.json` and its
signature. Both rebuild `updates/history.json` and its signature from real
Gitea releases, following API pagination so older rollback entries are retained.
The four metadata files are committed atomically through the Gitea contents API.
Existing release detection is fail-closed. Never delete, move, or reuse a tag
after publication. Fix a failed release with a new patch, beta, or RC number.
## Withdrawing or blocking a release
Edit `release/statuses.json` on `main` and set the immutable tag to
`withdrawn` or `blocked`, for example:
```json
{
"releases": {
"v1.9.0-beta.2": "withdrawn"
},
"schema": 1
}
```
The trusted `release-status.yml` workflow rebuilds and signs `updates/history.json`
from real Gitea releases. It also rebuilds both channel manifests: each channel
points to its greatest active release, or carries an authenticated inactive
status when that channel has no active release. The workflow verifies every
selected release manifest before signing and atomically publishing the channel
and history files. This prevents a withdrawn channel head from continuing to be
offered by launchers that read `updates/stable.json` or
`updates/prerelease.json` directly.
The release assets remain available for support, while new automatic update and
rollback selection excludes the inactive release. Deleting the tag's override
returns it to `active`. A status entry can also declare compatibility when a
future release changes the persistent settings schema:
```json
{
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 2,
"minimum_config_schema": 1
}
},
"schema": 1
}
```
The workflow can be run manually after a runner or API outage.
## Launcher history and rollback
The launcher fetches and verifies `updates/history.json` and its detached
signature for the version-history window. An explicit historical rollback uses
that release's immutable `manifest.json`, `manifest.json.sig`, and versioned
archive:
```text
pristontale-eu launcher-update --rollback 1.8.0
```
Running `--rollback` without a version keeps the original fast path to the
locally retained previous release. Every remote target is signature-, size-,
archive-hash-, and per-file-hash checked before it enters the immutable release
store. The launcher also persists that authenticated file inventory and verifies
it again before reactivating a locally retained release. Releases installed by an
older launcher remain usable through the compatibility path when no persisted
inventory exists. Complete release directories make reconciliation exact: files
managed by the current release but absent from the target disappear when
`current` changes, while settings, logs, saves, and downloads remain outside the
release tree.
Activation records the original `current` and `previous` links in a durable
transaction journal. Link or post-activation validation failure restores those
links, and the next launcher run recovers an interrupted transaction before any
new mutation. The local operation history retains the latest 30 update and
rollback results.
See `SIGNING_MIGRATION.md` before rotating the deployed manifest key.

View File

@@ -0,0 +1,35 @@
# Manifest signing migration
The installed launcher population currently pins the 3072-bit RSA public key in
`ptlinux/launcher-update-public.pem`. Release scripts also support OpenSSL
Ed25519 keys, but changing the Gitea secret before clients receive the new
public key would make all metadata unverifiable.
Use this staged migration when Ed25519 is adopted:
1. Generate the Ed25519 key pair offline. Keep the private key out of the
repository and record the public-key fingerprint through the normal release
review process.
2. Publish a normal RSA-signed transition release. That immutable archive adds
the Ed25519 public key and updater support, while its channel manifest still
verifies with the RSA key already installed on clients.
3. Verify the transition release from a clean pre-transition installation on
both stable and prerelease channel paths. Confirm rollback still verifies old
RSA-signed historical manifests.
4. Keep `manifest.json.sig` RSA-signed while enough installations move to the
transition release. If dual signatures are introduced, upload the Ed25519
signature under a separate filename; never change the meaning of the legacy
`.sig` asset for old clients.
5. Publish a second RSA-signed release that makes the updater choose signatures
by an authenticated algorithm and key identifier, and bundles a keyring with
both public keys. Its minimum updater compatibility must prevent an older
verifier from trusting metadata it cannot authenticate.
6. Replace the protected Actions signing secret only after the keyring release
is deployed and measured. Exercise stable discovery, prerelease discovery,
remote rollback, withdrawal publication, and tamper rejection with Ed25519.
7. Retire RSA verification in a later release. Retain the old public key for
immutable historical rollback metadata until those releases are outside the
supported rollback window.
At every stage, a failed or incorrectly signed tag remains immutable. Correct
the pipeline and publish the next semantic version rather than replacing assets.

140
release/config.json Normal file
View File

@@ -0,0 +1,140 @@
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"wrapped_bridge_versions": [
"0.9.10",
"0.9.11"
],
"channels": [
"stable",
"prerelease"
],
"manifest_version": 1,
"config_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.9.9",
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux",
"allowed_git_remotes": [
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git"
],
"version_mirrors": [
"pyproject.toml"
],
"version_source": "ptlinux/__init__.py",
"payload": {
"python_trees": [
{
"exclude": [
"desktop_entry.py",
"launcher_wrapper.py"
],
"source": "ptlinux"
}
],
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem"
},
{
"mode": "0644",
"path": "ptdiag/__init__.py"
},
{
"mode": "0644",
"path": "ptdiag/assbin.py"
},
{
"mode": "0644",
"path": "ptdiag/logs.py"
},
{
"mode": "0644",
"path": "ptlinux/native/pt-identity-probe.exe"
},
{
"mode": "0755",
"path": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.README"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-compat.copyright"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-lazy-output-images.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-live-resize.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-letterbox-backing.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch"
},
{
"mode": "0644",
"path": "ptlinux/native/gamescope-keep-nested-windows-visible.patch"
}
]
},
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/__main__.py",
"ptlinux/cli.py",
"ptlinux/self_update.py",
"ptlinux/update_resolver.py",
"ptlinux/launcher-update-public.pem"
],
"exclusions": [
".git",
".gitea",
".idea",
".run",
".venv",
".vscode",
"dist",
"logs",
"results",
"test-output",
"tests",
"testcases",
"tools",
"__pycache__",
"*.pyc",
"*.pyo",
".env",
".env.*",
"*.pem",
"*.key",
"id_rsa",
"id_ed25519",
"*.part",
"*.tmp",
"downloads"
],
"allowed_pem_files": [
"ptlinux/launcher-update-public.pem"
],
"preflight": [
"\"$PTLINUX_PYTHON\" -m compileall -q ptlinux ptdiag scripts release_tests",
"\"$PTLINUX_PYTHON\" -m unittest discover -s release_tests -p 'test_*.py'",
"\"$PTLINUX_PYTHON\" -m unittest discover -s tests -p 'test_*.py'"
]
}

4
release/statuses.json Normal file
View File

@@ -0,0 +1,4 @@
{
"releases": {},
"schema": 1
}

View File

@@ -0,0 +1 @@
"""Tracked preflight tests for the automated release system."""

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from unittest.mock import patch
import unittest
from ptlinux.self_update import (
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL,
DEFAULT_STABLE_UPDATE_MANIFEST_URL,
UpdateManifest,
check_for_launcher_update,
configured_manifest_urls,
)
def manifest(version: str, channel: str, *, status: str = "active") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T00:00:00Z",
notes_url=f"https://example.test/releases/v{version}",
tag=f"v{version}",
status=status,
)
class ChannelDiscoveryTests(unittest.TestCase):
def test_stable_fetches_only_stable_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="stable", environment={}),
(("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),),
)
def test_prerelease_fetches_stable_and_optional_prerelease_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="prerelease", environment={}),
(
("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),
("prerelease", DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, True),
),
)
def test_stable_client_ignores_a_newer_prerelease(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
) as fetch:
check = check_for_launcher_update(
current_version="1.7.4",
channel="stable",
)
self.assertEqual(fetch.call_count, 1)
self.assertEqual(check.latest_version, "1.8.0")
self.assertEqual(check.manifest.channel, "stable")
self.assertTrue(check.available)
def test_prerelease_client_chooses_the_highest_eligible_release(self) -> None:
def fetch(url: str, *_args: object, **_kwargs: object) -> UpdateManifest:
if url == DEFAULT_STABLE_UPDATE_MANIFEST_URL:
return manifest("1.8.0", "stable")
return manifest("1.9.0-beta.2", "prerelease")
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
side_effect=fetch,
):
check = check_for_launcher_update(
current_version="1.9.0-beta.1",
channel="prerelease",
)
self.assertEqual(check.latest_version, "1.9.0-beta.2")
self.assertEqual(check.manifest.channel, "prerelease")
self.assertTrue(check.available)
def test_returning_to_stable_never_offers_an_older_stable_release(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
):
check = check_for_launcher_update(
current_version="1.9.0-beta.2",
channel="stable",
)
self.assertFalse(check.available)
self.assertIn("will not downgrade", check.reason)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import unittest
from scripts.prepare_channel_metadata import prepare_channel_metadata
from scripts.release_lib import ReleaseToolError
def manifest(version: str, channel: str) -> bytes:
value = {
"application": "PristonTale-EU",
"archive": {
"name": f"PristonTale-EU-{version}.tar.gz",
"sha256": "a" * 64,
"size": 123,
"url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
"channel": channel,
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": f"https://gitea.example.test/releases/tag/v{version}",
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
}
return (json.dumps(value, sort_keys=True) + "\n").encode()
def record(version: str, channel: str, status: str) -> dict[str, object]:
return {
"channel": channel,
"compatibility": {
"config_schema": 1,
"minimum_config_schema": 1,
},
"manifest_url": f"https://gitea.example.test/releases/download/v{version}/manifest.json",
"status": status,
"tag": f"v{version}",
"version": version,
}
class ChannelMetadataTests(unittest.TestCase):
def test_selects_latest_active_release_instead_of_withdrawn_head(self) -> None:
history = {
"schema": 1,
"releases": [
record("1.9.0", "stable", "withdrawn"),
record("1.8.0", "stable", "active"),
],
}
selected, prepared = prepare_channel_metadata(
history,
"stable",
manifest("1.8.0", "stable"),
)
self.assertEqual(selected["version"], "1.8.0")
self.assertEqual(prepared["status"], "active")
def test_marks_channel_inactive_when_no_active_release_exists(self) -> None:
history = {
"schema": 1,
"releases": [record("1.9.0-beta.1", "prerelease", "blocked")],
}
selected, prepared = prepare_channel_metadata(
history,
"prerelease",
manifest("1.9.0-beta.1", "prerelease"),
)
self.assertEqual(selected["version"], "1.9.0-beta.1")
self.assertEqual(prepared["status"], "blocked")
def test_rejects_a_manifest_that_does_not_match_selected_history(self) -> None:
history = {
"schema": 1,
"releases": [record("1.8.0", "stable", "active")],
}
with self.assertRaisesRegex(ReleaseToolError, "does not match"):
prepare_channel_metadata(
history,
"stable",
manifest("1.7.4", "stable"),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from ptlinux.paths import AppPaths
from ptlinux.settings import Settings, load_settings, save_settings
class ChannelSettingsTests(unittest.TestCase):
def test_existing_users_default_to_stable(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"realm": "Babel"}\n', encoding="utf-8")
self.assertEqual(load_settings(paths).update_channel, "stable")
def test_prerelease_preference_round_trips(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
save_settings(paths, Settings(update_channel="prerelease"))
self.assertEqual(load_settings(paths).update_channel, "prerelease")
def test_unknown_channel_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"update_channel": "nightly"}\n', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "update channel"):
load_settings(paths)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,643 @@
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from scripts.release_lib import verify_signature
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MOCK_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
FORM=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--form)
FORM="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
release_json()
{
local assets
assets="$(jq -s '.' "${MOCK_STATE_DIR}"/asset-*.json)"
jq -n \
--arg html_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" \
--arg published_at "2026-09-08T12:00:00Z" \
--arg tag "$RELEASE_TAG" \
--arg name "PristonTale EU Launcher ${MOCK_VERSION}" \
--arg target_commitish "$RELEASE_COMMIT" \
--argjson assets "$assets" \
--argjson prerelease "$RELEASE_PRERELEASE" \
'{id:71,tag_name:$tag,name:$name,draft:false,prerelease:$prerelease,target_commitish:$target_commitish,published_at:$published_at,created_at:$published_at,html_url:$html_url,assets:$assets}'
}
case "${METHOD} ${URL}" in
"GET "*"/releases/tags/"*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "existing" ]]
then
printf 'curl: (28) simulated release lookup timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_EXISTING_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release lookup failure"}' > "$OUTPUT"
printf '%s' "$MOCK_EXISTING_STATUS"
exit 0
fi
if [[ -f "${MOCK_STATE_DIR}/created" ]]
then
release_json > "$OUTPUT"
printf '200'
else
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
fi
;;
"POST "*"/releases")
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "create" ]]
then
printf 'curl: (28) simulated release creation timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_CREATE_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated release creation failure"}' > "$OUTPUT"
printf '%s' "$MOCK_CREATE_STATUS"
exit 0
fi
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/release-payload.json"
: > "${MOCK_STATE_DIR}/created"
printf '%s\n' '{"id":71}' > "$OUTPUT"
printf '201'
;;
"POST "*"/assets?name="*)
if [[ "${MOCK_TIMEOUT_STAGE:-}" == "asset" ]]
then
printf 'curl: (28) simulated attachment timeout\n' >&2
exit 28
fi
if [[ -n "${MOCK_ASSET_STATUS:-}" ]]
then
printf '%s\n' '{"message":"simulated attachment failure"}' > "$OUTPUT"
printf '%s' "$MOCK_ASSET_STATUS"
exit 0
fi
name="${URL##*?name=}"
file="${FORM#attachment=@}"
file="${file%%;*}"
size="$(stat -c '%s' -- "$file")"
jq -n \
--arg name "$name" \
--arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${name}" \
--argjson size "$size" \
'{name:$name,size:$size,browser_download_url:$url}' > "$OUTPUT"
cp -- "$OUTPUT" "${MOCK_STATE_DIR}/asset-${name}.json"
printf '201'
;;
"GET "*"/releases?limit=100&page=1")
if [[ "${MOCK_RELEASE_PAGINATION:-}" == "1" ]]
then
jq -n '[range(0;100) as $index | {draft:true,tag_name:("ignored-" + ($index|tostring))}]' > "$OUTPUT"
else
release_json | jq -s '.' > "$OUTPUT"
fi
printf '200'
;;
"GET "*"/releases?limit=100&page=2")
release_json | jq -s '.' > "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/channel-payload.json"
printf '%s\n' '{"commit":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected mock curl request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
MOCK_HISTORY_CURL = r'''#!/usr/bin/env bash
set -euo pipefail
METHOD="GET"
OUTPUT=""
DATA_FILE=""
URL=""
while (( $# > 0 ))
do
case "$1" in
--request)
METHOD="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--data-binary)
DATA_FILE="${2#@}"
shift 2
;;
--connect-timeout|--max-time|--header|--write-out)
shift 2
;;
--silent|--show-error)
shift
;;
*)
URL="$1"
shift
;;
esac
done
case "${METHOD} ${URL}" in
"GET "*"/releases?limit=100&page=1")
cp -- "$MOCK_RELEASES" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json.sig")
cp -- "$MOCK_MANIFEST_SIGNATURE" "$OUTPUT"
printf '200'
;;
"GET "*"/manifest.json")
cp -- "$MOCK_MANIFEST" "$OUTPUT"
printf '200'
;;
"GET "*"/contents/"*)
printf '%s\n' '{"message":"not found"}' > "$OUTPUT"
printf '404'
;;
"POST "*"/contents")
cp -- "$DATA_FILE" "$MOCK_HISTORY_PAYLOAD"
printf '%s\n' '{"commit":{"sha":"cccccccccccccccccccccccccccccccccccccccc"}}' > "$OUTPUT"
printf '201'
;;
*)
printf 'Unexpected history mock request: %s %s\n' "$METHOD" "$URL" >&2
exit 90
;;
esac
'''
def _publisher_manifest(version: str = "1.8.0") -> bytes:
archive_name = f"PristonTale-EU-{version}.tar.gz"
archive_url = (
f"https://gitea.example.test/owner/project/releases/download/"
f"v{version}/{archive_name}"
)
return (
json.dumps(
{
"application": "PristonTale-EU",
"archive": {
"name": archive_name,
"sha256": "a" * 64,
"size": 123,
"url": archive_url,
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": archive_url,
"channel": "stable",
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
},
"minimum_updater_version": "0.5.0",
"notes_url": (
f"https://gitea.example.test/owner/project/releases/tag/v{version}"
),
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
},
indent=2,
sort_keys=True,
)
+ "\n"
).encode()
class GiteaPublisherTests(unittest.TestCase):
def test_publishes_assets_and_updates_only_the_selected_channel(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
(output / "manifest.json").write_text('{"schema":1}\n', encoding="utf-8")
(output / "manifest.json.sig").write_bytes(b"manifest signature")
(output / "release-notes.md").write_text("Release notes.\n", encoding="utf-8")
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_STATE_DIR": str(state),
"MOCK_RELEASE_PAGINATION": "1",
"MOCK_VERSION": "0.9.8",
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
release_payload = json.loads(
(state / "release-payload.json").read_text(encoding="utf-8")
)
self.assertFalse(release_payload["draft"])
self.assertFalse(release_payload["prerelease"])
self.assertEqual(release_payload["tag_name"], "v0.9.8")
self.assertEqual(release_payload["body"], "Release notes.\n")
self.assertEqual(len(list(state.glob("asset-*.json"))), 5)
channel_payload = json.loads(
(state / "channel-payload.json").read_text(encoding="utf-8")
)
paths = {operation["path"] for operation in channel_payload["files"]}
self.assertIn("updates/stable.json", paths)
self.assertIn("updates/stable.json.sig", paths)
self.assertNotIn("updates/prerelease.json", paths)
self.assertIn("updates/history.json", paths)
self.assertIn("Status | Published", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
second = subprocess.run(
[
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(second.returncode, 0)
self.assertIn("already exists", second.stderr)
def test_status_publication_rebuilds_signed_channel_metadata(self) -> None:
openssl = shutil.which("openssl")
jq = shutil.which("jq")
if openssl is None or jq is None:
self.skipTest("OpenSSL and jq are required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake_bin = root / "bin"
fake_bin.mkdir()
private_key = root / "private.pem"
public_key = root / "public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
manifest = root / "manifest.json"
manifest.write_bytes(_publisher_manifest())
manifest_signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"dgst",
"-sha256",
"-sign",
str(private_key),
"-out",
str(manifest_signature),
str(manifest),
],
check=True,
capture_output=True,
)
releases = root / "releases.json"
releases.write_text(
json.dumps(
[
{
"assets": [
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json"
),
"name": "manifest.json",
},
{
"browser_download_url": (
"https://gitea.example.test/owner/project/"
"releases/download/v1.8.0/manifest.json.sig"
),
"name": "manifest.json.sig",
},
],
"draft": False,
"html_url": (
"https://gitea.example.test/owner/project/releases/tag/v1.8.0"
),
"id": 71,
"name": "PristonTale EU Launcher 1.8.0",
"prerelease": False,
"published_at": "2026-09-08T12:00:00Z",
"tag_name": "v1.8.0",
}
]
),
encoding="utf-8",
)
statuses = root / "statuses.json"
statuses.write_text(
(
'{"releases":{"v1.8.0":{"config_schema":2,'
'"minimum_config_schema":1,"status":"withdrawn"}},"schema":1}\n'
),
encoding="utf-8",
)
mock_curl = fake_bin / "curl"
mock_curl.write_text(MOCK_HISTORY_CURL, encoding="utf-8")
mock_curl.chmod(0o755)
payload = root / "history-payload.json"
summary = root / "summary.md"
environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"GITHUB_STEP_SUMMARY": str(summary),
"MOCK_HISTORY_PAYLOAD": str(payload),
"MOCK_MANIFEST": str(manifest),
"MOCK_MANIFEST_SIGNATURE": str(manifest_signature),
"MOCK_RELEASES": str(releases),
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(private_key),
"PTLINUX_UPDATE_PUBLIC_KEY": str(public_key),
"RELEASE_BRANCH": "main",
"RELEASE_STATUS_FILE": str(statuses),
"RELEASE_TOKEN": "mock-release-token",
}
result = subprocess.run(
[str(PROJECT_ROOT / "scripts/publish-release-history.sh"), str(root / "dist")],
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
published = json.loads(payload.read_text(encoding="utf-8"))
files = {item["path"]: item for item in published["files"]}
self.assertEqual(
set(files),
{
"updates/history.json",
"updates/history.json.sig",
"updates/stable.json",
"updates/stable.json.sig",
},
)
stable = json.loads(base64.b64decode(files["updates/stable.json"]["content"]))
history = json.loads(base64.b64decode(files["updates/history.json"]["content"]))
self.assertEqual(stable["status"], "withdrawn")
self.assertEqual(history["releases"][0]["status"], "withdrawn")
self.assertEqual(
history["releases"][0]["compatibility"],
{"config_schema": 2, "minimum_config_schema": 1},
)
self.assertEqual(
stable["compatibility"]["config_schema"],
2,
)
published_stable = root / "published-stable.json"
published_stable_signature = root / "published-stable.json.sig"
published_stable.write_bytes(
base64.b64decode(files["updates/stable.json"]["content"])
)
published_stable_signature.write_bytes(
base64.b64decode(files["updates/stable.json.sig"]["content"])
)
verify_signature(published_stable, published_stable_signature, public_key)
self.assertIn("stable channel: `1.8.0`", summary.read_text(encoding="utf-8"))
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
def test_api_failures_never_report_publication_success(self) -> None:
if shutil.which("jq") is None:
self.skipTest("jq is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
output = root / "dist"
state = root / "state"
fake_bin = root / "bin"
for directory in (output, state, fake_bin):
directory.mkdir()
archive = output / "PristonTale-EU-0.9.8.tar.gz"
archive.write_bytes(b"archive fixture")
(output / f"{archive.name}.sha256").write_text(
f"{'a' * 64} {archive.name}\n",
encoding="ascii",
)
for name in ("manifest.json", "manifest.json.sig", "release-notes.md"):
(output / name).write_text("fixture\n", encoding="utf-8")
fake_curl = fake_bin / "curl"
fake_curl.write_text(MOCK_CURL, encoding="utf-8")
fake_curl.chmod(0o755)
base_environment = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ['PATH']}",
"GITHUB_API_URL": "https://gitea.example.test/api/v1",
"GITHUB_REPOSITORY": "owner/project",
"GITHUB_SERVER_URL": "https://gitea.example.test",
"MOCK_STATE_DIR": str(state),
"MOCK_VERSION": "0.9.8",
"RELEASE_BRANCH": "main",
"RELEASE_COMMIT": "a" * 40,
"RELEASE_PRERELEASE": "false",
"RELEASE_TAG": "v0.9.8",
"RELEASE_TOKEN": "mock-release-token",
}
command = [
str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"),
"0.9.8",
"stable",
str(output),
str(output / "release-notes.md"),
]
cases = [
("MOCK_EXISTING_STATUS", "401"),
("MOCK_EXISTING_STATUS", "403"),
("MOCK_EXISTING_STATUS", "500"),
("MOCK_CREATE_STATUS", "404"),
("MOCK_CREATE_STATUS", "409"),
("MOCK_CREATE_STATUS", "422"),
("MOCK_CREATE_STATUS", "500"),
("MOCK_ASSET_STATUS", "500"),
("MOCK_TIMEOUT_STAGE", "existing"),
("MOCK_TIMEOUT_STAGE", "create"),
("MOCK_TIMEOUT_STAGE", "asset"),
]
for variable, value in cases:
with self.subTest(variable=variable, value=value):
(state / "created").unlink(missing_ok=True)
environment = {**base_environment, variable: value}
result = subprocess.run(
command,
cwd=PROJECT_ROOT,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Published stable release", result.stdout)
self.assertNotIn("mock-release-token", result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from contextlib import redirect_stderr
import io
import unittest
from ptlinux.cli import build_parser
class LauncherUpdateCliTests(unittest.TestCase):
def test_bare_rollback_uses_retained_previous_release(self) -> None:
arguments = build_parser().parse_args(["launcher-update", "--rollback"])
self.assertEqual(arguments.rollback, "")
def test_explicit_rollback_accepts_version_and_history_override(self) -> None:
arguments = build_parser().parse_args(
[
"launcher-update",
"--rollback",
"1.8.0",
"--history-url",
"https://updates.example.test/history.json",
]
)
self.assertEqual(arguments.rollback, "1.8.0")
self.assertEqual(
arguments.history_url,
"https://updates.example.test/history.json",
)
def test_rollback_remains_exclusive_with_check_and_verify(self) -> None:
parser = build_parser()
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
parser.parse_args(["launcher-update", "--rollback", "--check"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import json
from pathlib import Path
from pathlib import PurePosixPath
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
_evaluate_launcher_update,
_stage_and_install_release,
parse_update_manifest,
)
from scripts.release_lib import (
ReleaseToolError,
_archive_layout_for_version,
_forbid_release_path,
_safe_output_directory,
build_release,
load_config,
sha256_file,
sign_file,
verify_release,
verify_signature,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseArtifactTests(unittest.TestCase):
def test_build_is_reproducible_and_matches_the_runtime_parser(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
notes = root / "notes.md"
notes.write_text("## Highlights\n\nChannel-aware updates.\n", encoding="utf-8")
commit = "a" * 40
first = root / "first"
second = root / "second"
for output in (first, second):
build_release(
__version__,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit=commit,
source_date_epoch=1_700_000_000,
release_notes_file=notes,
)
verify_release(__version__, "stable", output)
archive_name = f"PristonTale-EU-{__version__}.tar.gz"
self.assertEqual(
sha256_file(first / archive_name),
sha256_file(second / archive_name),
)
self.assertEqual(
(first / "manifest.json").read_bytes(),
(second / "manifest.json").read_bytes(),
)
manifest = parse_update_manifest((first / "manifest.json").read_bytes())
self.assertEqual(manifest.version, __version__)
self.assertEqual(manifest.channel, "stable")
self.assertEqual(manifest.tag, f"v{__version__}")
self.assertGreater(len(manifest.files), 10)
self.assertEqual(manifest.archive_name, archive_name)
raw_manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(
raw_manifest["archive_url"],
f"https://gitea.example.test/owner/project/releases/download/"
f"v{__version__}/{archive_name}?sha256={manifest.archive_sha256}",
)
expected_layout = _archive_layout_for_version(load_config(), __version__)
self.assertEqual(raw_manifest["archive"]["layout"], expected_layout)
self.assertIn("Channel-aware updates.", manifest.notes_markdown or "")
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(
paths,
first / archive_name,
manifest,
)
self.assertEqual(installed, paths.launcher_releases / __version__)
self.assertTrue((installed / "ptlinux/update_resolver.py").is_file())
with tarfile.open(first / archive_name, "r:gz") as bundle:
names = bundle.getnames()
archive_root = (
f"pristontale-eu-linux-update-v{__version__}/"
if expected_layout == "wrapped"
else ""
)
self.assertIn(f"{archive_root}release.json", names)
self.assertIn(f"{archive_root}ptlinux/__init__.py", names)
self.assertEqual(
any(name.startswith("pristontale-eu-linux-update-v") for name in names),
expected_layout == "wrapped",
)
self.assertFalse(any("__pycache__" in name for name in names))
self.assertFalse(any(name.endswith(".pyc") for name in names))
self.assertFalse(any("/.git/" in name for name in names))
def test_manifest_file_list_rejects_reordering(self) -> None:
manifest = {
"schema": 1,
"channel": "stable",
"version": "1.8.0",
"minimum_updater_version": "0.5.0",
"archive_url": "https://example.test/PristonTale-EU-1.8.0.tar.gz",
"archive_sha256": "a" * 64,
"archive_size": 100,
"published_at": "2026-09-08T00:00:00Z",
"notes_url": "https://example.test/releases/v1.8.0",
"files": [
{"path": "ptlinux/z.py", "size": 1, "sha256": "b" * 64},
{"path": "ptlinux/a.py", "size": 1, "sha256": "c" * 64},
],
}
with self.assertRaisesRegex(RuntimeError, "uniquely sorted"):
parse_update_manifest(
(json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8")
)
def test_manual_build_can_set_only_the_packaged_launcher_version(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target_version = "9.8.7-beta.2"
source_path = PROJECT_ROOT / "ptlinux/__init__.py"
source_before = source_path.read_bytes()
build_release(
target_version,
"prerelease",
root / "dist",
repository_url="https://gitea.example.test/owner/project",
commit="b" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(target_version, "prerelease", root / "dist")
archive = root / "dist" / f"PristonTale-EU-{target_version}.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
member = bundle.extractfile("ptlinux/__init__.py")
assert member is not None
self.assertIn(
f'__version__ = "{target_version}"'.encode(),
member.read(),
)
self.assertEqual(source_path.read_bytes(), source_before)
def test_configured_bridge_release_uses_legacy_wrapper(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
version = "0.9.10"
output = root / "dist"
build_release(
version,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit="c" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(version, "stable", output)
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["archive"]["layout"], "wrapped")
self.assertEqual(manifest["minimum_updater_version"], "0.5.0")
archive = output / f"PristonTale-EU-{version}.tar.gz"
archive_root = f"pristontale-eu-linux-update-v{version}"
with tarfile.open(archive, "r:gz") as bundle:
names = bundle.getnames()
self.assertIn(f"{archive_root}/release.json", names)
self.assertIn(f"{archive_root}/ptlinux/self_update.py", names)
self.assertFalse(any(name == "release.json" for name in names))
parsed = parse_update_manifest((output / "manifest.json").read_bytes())
check = _evaluate_launcher_update(
parsed,
"0.9.8",
"https://gitea.example.test/owner/project/raw/branch/main/updates/stable.json",
)
self.assertTrue(check.available)
self.assertTrue(check.supported)
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(paths, archive, parsed)
self.assertEqual(installed.name, version)
def test_output_cleaning_cannot_target_source_directories(self) -> None:
self.assertEqual(
_safe_output_directory(PROJECT_ROOT / "dist/v9.8.7"),
(PROJECT_ROOT / "dist/v9.8.7").resolve(),
)
with self.assertRaisesRegex(ReleaseToolError, "repository dist directory"):
_safe_output_directory(PROJECT_ROOT / "docs")
with self.assertRaisesRegex(ReleaseToolError, "unsafe output"):
_safe_output_directory(Path(tempfile.gettempdir()))
def test_forbidden_release_paths_are_rejected(self) -> None:
config = load_config()
for path in (
PurePosixPath(".env"),
PurePosixPath("private.key"),
PurePosixPath("ptlinux/__pycache__/module.pyc"),
PurePosixPath("logs/launcher.log"),
):
with self.subTest(path=path):
with self.assertRaisesRegex(ReleaseToolError, "forbidden"):
_forbid_release_path(path, config)
_forbid_release_path(
PurePosixPath("ptlinux/launcher-update-public.pem"),
config,
)
def test_signature_verification_rejects_modified_metadata(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "private.pem"
public_key = root / "public.pem"
metadata = root / "manifest.json"
signature = root / "manifest.json.sig"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
metadata.write_text('{"schema": 1}\n', encoding="utf-8")
sign_file(metadata, signature, private_key)
verify_signature(metadata, signature, public_key)
metadata.write_text('{"schema": 2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(metadata, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseCliTests(unittest.TestCase):
def setUp(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
self.temporary = tempfile.TemporaryDirectory()
base = Path(self.temporary.name)
self.repository = base / "working"
self.remote = base / "remote.git"
for relative in ("scripts", "ptlinux", "release"):
(self.repository / relative).mkdir(parents=True, exist_ok=True)
for relative in (
"scripts/__init__.py",
"scripts/build-release.sh",
"scripts/build_release.py",
"scripts/release.sh",
"scripts/release_lib.py",
"scripts/verify-release.sh",
"scripts/verify_release.py",
"ptlinux/update_resolver.py",
):
shutil.copy2(PROJECT_ROOT / relative, self.repository / relative)
(self.repository / "ptlinux/__init__.py").write_text(
'__version__ = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / "pyproject.toml").write_text(
'[project]\nname = "release-fixture"\nversion = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / ".gitignore").write_text(
"dist/\n__pycache__/\n*.pyc\n",
encoding="utf-8",
)
self.private_key = base / "private.pem"
public_key = self.repository / "ptlinux/launcher-update-public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(self.private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(self.private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
(self.repository / "release/config.json").write_text(
json.dumps(
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"channels": ["stable", "prerelease"],
"config_schema": 1,
"exclusions": [],
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"manifest_version": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
"payload": {
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem",
}
],
"python_trees": [{"exclude": [], "source": "ptlinux"}],
},
"preflight": [],
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://gitea.example.test/owner/project",
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/launcher-update-public.pem",
],
"allowed_pem_files": ["ptlinux/launcher-update-public.pem"],
"version_mirrors": ["pyproject.toml"],
"version_source": "ptlinux/__init__.py",
"wrapped_bridge_versions": [],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
self._run(["git", "init", "--bare", str(self.remote)], cwd=base)
self._git("init", "-b", "main")
self._git("config", "user.name", "Release Test")
self._git("config", "user.email", "release-test@example.test")
self._git("add", ".")
self._git("commit", "-m", "Initial launcher release")
self._git("tag", "-a", "v1.7.4", "-m", "Launcher 1.7.4")
self._git("remote", "add", "origin", str(self.remote))
self._git("push", "-u", "origin", "main", "v1.7.4")
(self.repository / "change.txt").write_text("update\n", encoding="utf-8")
self._git("add", "change.txt")
self._git("commit", "-m", "Improve updater reliability")
self._git("push", "origin", "main")
def tearDown(self) -> None:
self.temporary.cleanup()
def _run(
self,
command: list[str],
*,
cwd: Path | None = None,
environment: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd or self.repository,
env=environment,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
check=False,
)
def _git(self, *arguments: str) -> str:
result = self._run(["git", *arguments])
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.strip()
def _release(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess[str]:
return self._run(
[str(self.repository / "scripts/release.sh"), *arguments],
environment={
**os.environ,
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(self.private_key),
**environment,
},
)
def test_changelog_modes_preserve_markdown_and_do_not_mutate_git(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
inline = self._release(
"stable",
"1.8.0",
"--description",
"## Highlights\n\n- Signed channels",
"--changelog-only",
)
self.assertEqual(inline.returncode, 0, inline.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertLess(notes.index("## Highlights"), notes.index("## Changes"))
self.assertIn("Improve updater reliability", notes)
description = Path(self.temporary.name) / "description.md"
description.write_text("## Fixes\n\n- Preserved **Markdown**", encoding="utf-8")
from_file = self._release(
"stable",
"1.8.0",
"--description-file",
str(description),
"--no-auto-changelog",
"--changelog-only",
)
self.assertEqual(from_file.returncode, 0, from_file.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertIn("- Preserved **Markdown**", notes)
self.assertNotIn("## Changes", notes)
editor = Path(self.temporary.name) / "editor"
editor.write_text(
'#!/usr/bin/env bash\nprintf "\\nEdited before publication.\\n" >> "$1"\n',
encoding="utf-8",
)
editor.chmod(0o755)
edited = self._release(
"stable",
"1.8.0",
"--editor",
"--changelog-only",
EDITOR=str(editor),
)
self.assertEqual(edited.returncode, 0, edited.stderr)
self.assertIn(
"Edited before publication.",
(self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
),
)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
def test_dry_run_then_builds_manual_stable_and_prerelease_packages(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
remote_before = self._git("ls-remote", "origin")
dry_run = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
"--dry-run",
)
self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
self.assertIn("Dry run complete", dry_run.stdout)
self.assertFalse((self.repository / "dist/v1.8.0/manifest.json").exists())
stable = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
)
self.assertEqual(stable.returncode, 0, stable.stderr)
stable_output = self.repository / "dist/v1.8.0"
manifest_path = stable_output / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.8.0")
self.assertEqual(manifest["channel"], "stable")
self.assertTrue((stable_output / "manifest.json.sig").is_file())
self.assertEqual(
manifest_path.read_bytes(),
(stable_output / "channel/stable.json").read_bytes(),
)
archive = stable_output / "PristonTale-EU-1.8.0.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
version_source = bundle.extractfile("ptlinux/__init__.py")
assert version_source is not None
self.assertIn(b'__version__ = "1.8.0"', version_source.read())
self.assertIn("leave the Gitea", stable.stdout)
prerelease = self._release(
"prerelease",
"1.9.0-beta.1",
"--description",
"First beta",
"--skip-tests",
)
self.assertEqual(prerelease.returncode, 0, prerelease.stderr)
prerelease_output = self.repository / "dist/v1.9.0-beta.1"
manifest = json.loads(
(prerelease_output / "manifest.json").read_text(encoding="utf-8")
)
self.assertEqual(manifest["version"], "1.9.0-beta.1")
self.assertEqual(manifest["channel"], "prerelease")
self.assertTrue(manifest_path.is_file())
self.assertIn('select the Gitea "Pre-release" checkbox', prerelease.stdout)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
self.assertEqual(self._git("ls-remote", "origin"), remote_before)
self.assertIn('1.7.4', (self.repository / "ptlinux/__init__.py").read_text())
self.assertIn('1.7.4', (self.repository / "pyproject.toml").read_text())
def test_rejects_a_channel_version_mismatch_before_mutation(self) -> None:
result = self._release(
"stable",
"1.9.0-beta.1",
"--changelog-only",
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self._git("tag", "-l", "v1.9.0-beta.1"), "")
def test_build_does_not_depend_on_release_branch_or_clean_worktree(self) -> None:
self._git("switch", "-c", "release-candidate")
(self.repository / "uncommitted.txt").write_text("local source\n", encoding="utf-8")
result = self._release(
"stable",
"1.8.0",
"--skip-tests",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._git("tag", "-l", "v1.8.0"), "")
self.assertTrue((self.repository / "dist/v1.8.0/manifest.json").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import unittest
from scripts.generate_history import load_release_statuses, release_history
from scripts.release_lib import ReleaseToolError
import json
from pathlib import Path
import tempfile
def gitea_release(tag: str, *, prerelease: bool) -> dict[str, object]:
return {
"id": 42,
"tag_name": tag,
"name": f"Release {tag}",
"draft": False,
"prerelease": prerelease,
"published_at": "2026-09-08T12:00:00Z",
"html_url": f"https://gitea.example.test/owner/project/releases/tag/{tag}",
"assets": [
{
"name": "manifest.json",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json"
),
},
{
"name": "manifest.json.sig",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json.sig"
),
},
],
}
class ReleaseHistoryTests(unittest.TestCase):
def test_history_uses_only_installable_immutable_releases(self) -> None:
stable = gitea_release("v1.8.0", prerelease=False)
beta = gitea_release("v1.9.0-beta.2", prerelease=True)
wrong_flag = gitea_release("v2.0.0-beta.1", prerelease=False)
draft = gitea_release("v2.0.0", prerelease=False)
draft["draft"] = True
history = release_history(
[stable, wrong_flag, draft, beta],
"2026-09-08T12:01:00Z",
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
[release["version"] for release in releases],
["1.9.0-beta.2", "1.8.0"],
)
self.assertEqual(releases[0]["channel"], "prerelease")
self.assertEqual(releases[1]["channel"], "stable")
self.assertTrue(releases[0]["manifest_url"].endswith("/manifest.json"))
def test_history_rejects_an_invalid_generation_timestamp(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "timestamp"):
release_history([], "today")
def test_history_applies_signed_release_status_overrides(self) -> None:
history = release_history(
[gitea_release("v1.9.0-beta.2", prerelease=True)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.2": "withdrawn"},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(releases[0]["status"], "withdrawn")
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 1, "minimum_config_schema": 1},
)
def test_history_supports_explicit_config_compatibility(self) -> None:
history = release_history(
[gitea_release("v2.0.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{
"v2.0.0": {
"status": "active",
"config_schema": 3,
"minimum_config_schema": 2,
}
},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 3, "minimum_config_schema": 2},
)
def test_status_file_rejects_unknown_fields_and_unsafe_schema_ranges(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "statuses.json"
path.write_text(
json.dumps(
{
"schema": 1,
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 1,
"minimum_config_schema": 2,
}
},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "exceeds"):
load_release_statuses(path)
path.write_text(
'{"schema":1,"releases":{"v2.0.0":{"status":"active","note":"x"}}}',
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "unknown"):
load_release_statuses(path)
def test_history_rejects_an_unknown_status(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "invalid status"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.8.0": "broken"},
)
def test_history_rejects_status_override_for_a_missing_release(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "do not match"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.1": "withdrawn"},
)
def test_history_rejects_duplicate_release_tags(self) -> None:
release = gitea_release("v1.8.0", prerelease=False)
with self.assertRaisesRegex(ReleaseToolError, "duplicate"):
release_history(
[release, dict(release)],
"2026-09-08T12:01:00Z",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from scripts.validate_release_remote import remote_matches, repository_identity
class ReleaseRemoteTests(unittest.TestCase):
def test_https_and_ssh_forms_identify_the_same_repository(self) -> None:
expected = repository_identity(
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
self.assertEqual(
repository_identity("git@git.m4ko.net:m4ko/pristontale-eu-linux.git"),
expected,
)
self.assertEqual(
repository_identity(
"ssh://git@git.m4ko.net/m4ko/pristontale-eu-linux.git"
),
expected,
)
def test_file_url_and_local_path_identify_the_same_repository(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "remote.git"
self.assertEqual(
repository_identity(path.as_uri()),
repository_identity(str(path)),
)
def test_remote_must_match_one_explicitly_allowed_identity(self) -> None:
self.assertTrue(
remote_matches(
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
[
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
],
)
)
self.assertFalse(
remote_matches(
"ssh://git@untrusted.test/m4ko/pristontale-eu-linux.git",
["https://git.m4ko.net/m4ko/pristontale-eu-linux"],
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherRemoteHistory,
LauncherRemoteRelease,
LauncherUpdateError,
ManifestFile,
UpdateManifest,
_activate_release,
_fetch_historical_manifest,
_recover_interrupted_activation,
_validate_release_directory,
launcher_rollback_candidates,
parse_launcher_release_history,
rollback_launcher_update,
)
def history_release(
version: str,
channel: str,
*,
status: str = "active",
) -> dict[str, object]:
tag = f"v{version}"
base = f"https://updates.example.test/releases/download/{tag}"
return {
"channel": channel,
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
"manifest_signature_url": f"{base}/manifest.json.sig",
"manifest_url": f"{base}/manifest.json",
"release_date": "2026-09-08T12:00:00Z",
"release_id": 42,
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
"status": status,
"tag": tag,
"title": f"Launcher {version}",
"version": version,
}
def history_bytes(releases: list[dict[str, object]]) -> bytes:
return (
json.dumps(
{
"application": "PristonTale-EU",
"generated_at": "2026-09-08T12:01:00Z",
"releases": releases,
"schema": 1,
},
sort_keys=True,
)
+ "\n"
).encode("utf-8")
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
files = {
"release.json": (
json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
},
sort_keys=True,
)
+ "\n"
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
if extra_file:
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
release = root / "payload"
make_release(release, version)
archive = root / f"PristonTale-EU-{version}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
for path in sorted(release.rglob("*")):
relative = path.relative_to(release).as_posix()
info = tarfile.TarInfo(relative)
info.mode = 0o755 if path.is_dir() else 0o644
if path.is_dir():
info.type = tarfile.DIRTYPE
bundle.addfile(info)
else:
data = path.read_bytes()
info.size = len(data)
bundle.addfile(info, io.BytesIO(data))
files = tuple(
ManifestFile(
path=path.relative_to(release).as_posix(),
size=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
)
for path in sorted(release.rglob("*"))
if path.is_file()
)
return archive, files
class RemoteHistoryTests(unittest.TestCase):
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir(mode=0o775)
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
cache.chmod(0o775)
self.assertEqual(_validate_release_directory(release), "1.8.0")
self.assertFalse(cache.exists())
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir()
unexpected = cache / "payload.txt"
unexpected.write_text("not bytecode\n", encoding="utf-8")
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
_validate_release_directory(release)
self.assertTrue(unexpected.is_file())
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
data = history_bytes(
[
history_release("1.9.0-beta.2", "prerelease"),
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
history_release("1.8.0", "stable"),
history_release("1.7.4", "stable"),
]
)
history = parse_launcher_release_history(
data,
history_url="https://updates.example.test/updates/history.json",
)
stable = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="stable",
)
prerelease = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="prerelease",
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.8.0", "1.7.4"],
)
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
parse_launcher_release_history(
history_bytes(
[
history_release("1.8.0", "stable"),
history_release("1.9.0", "stable"),
]
),
history_url="https://updates.example.test/updates/history.json",
)
detached = history_release("1.8.0", "stable")
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
parse_launcher_release_history(
history_bytes([detached]),
history_url="https://updates.example.test/updates/history.json",
)
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
config_schema=3,
minimum_config_schema=2,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=1,
minimum_config_schema=1,
)
with (
patch(
"ptlinux.self_update._fetch_small_file",
side_effect=[b"manifest", b"signature"],
),
patch("ptlinux.self_update._verify_manifest_signature"),
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
):
result = _fetch_historical_manifest(
record,
Path("public.pem"),
timeout_seconds=1,
)
self.assertEqual(result.config_schema, 3)
self.assertEqual(result.minimum_config_schema, 2)
class RemoteRollbackTests(unittest.TestCase):
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0-beta.3"
make_release(current, current.name, extra_file=True)
paths.launcher_current.symlink_to(Path("releases") / current.name)
archive, files = archived_release(root, "1.8.0")
archive_bytes = archive.read_bytes()
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
archive_size=len(archive_bytes),
published_at="2026-09-08T12:00:00Z",
notes_url="https://updates.example.test/releases/tag/v1.8.0",
tag="v1.8.0",
files=files,
manifest_sha256="a" * 64,
)
record_value = history_release("1.8.0", "stable")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url=str(record_value["manifest_url"]),
manifest_signature_url=str(record_value["manifest_signature_url"]),
release_date=str(record_value["release_date"]),
release_notes_url=str(record_value["release_notes_url"]),
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/updates/history.json",
releases=(record,),
history_sha256="b" * 64,
)
paths.config.mkdir(parents=True)
paths.settings_file.write_text(
json.dumps({"schema": 1, "user_choice": "preserve me"}),
encoding="utf-8",
)
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive_bytes)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
patch("ptlinux.self_update._self_check_release"),
):
result = rollback_launcher_update(
paths,
target_version="1.8.0",
selected_channel="prerelease",
)
self.assertEqual(result.from_version, "1.9.0-beta.3")
self.assertEqual(result.to_version, "1.8.0")
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
self.assertEqual(
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
"preserve me",
)
operations = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)["operations"]
self.assertEqual(operations[-1]["reason"], "manual")
self.assertTrue(operations[-1]["rollback"])
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
self.assertTrue(integrity_record.is_file())
(paths.launcher_current / "ptlinux/__init__.py").write_text(
"locally modified after verification\n",
encoding="utf-8",
)
_activate_release(paths, current)
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=3,
minimum_config_schema=2,
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with (
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
self.assertRaisesRegex(RuntimeError, "active managed game client"),
):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_precheck_rejects_insufficient_disk_space(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch(
"ptlinux.self_update.shutil.disk_usage",
return_value=SimpleNamespace(free=0),
),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
from ptlinux import self_update
real_replace = self_update._replace_release_link
calls = 0
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated current-link failure")
real_replace(link, release, releases)
with (
patch(
"ptlinux.self_update._replace_release_link",
side_effect=fail_current_once,
),
self.assertRaisesRegex(OSError, "simulated current-link failure"),
):
_activate_release(paths, target)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "applying",
"current": current.name,
"previous": previous.name,
"target": "1.9.0",
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
self.assertEqual(
[call.args[0] for call in send.call_args_list],
["rollback_restore_started", "rollback_restore_success"],
)
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / target.name)
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "activated",
"current": current.name,
"previous": previous.name,
"target": target.name,
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
send.assert_not_called()
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with patch(
"ptlinux.self_update._write_json",
side_effect=OSError("read-only diagnostics directory"),
):
result = rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
self.assertFalse(result.state_recorded)
self.assertIn("could not be saved", result.warning or "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from scripts.release_lib import ReleaseToolError, sign_file, verify_signature
class SigningTests(unittest.TestCase):
def test_ed25519_metadata_signatures_and_tamper_rejection(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "manifest-ed25519-private.pem"
public_key = root / "manifest-ed25519-public.pem"
generated = subprocess.run(
[
openssl,
"genpkey",
"-algorithm",
"ED25519",
"-out",
str(private_key),
],
capture_output=True,
text=True,
check=False,
)
if generated.returncode != 0:
self.skipTest("installed OpenSSL does not support Ed25519")
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
capture_output=True,
text=True,
check=True,
)
private_key.chmod(0o600)
source = root / "manifest.json"
signature = root / "manifest.json.sig"
source.write_text('{"schema":1}\n', encoding="utf-8")
sign_file(source, signature, private_key)
verify_signature(source, signature, public_key)
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
source.write_text('{"schema":2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(source, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import unittest
from ptlinux.ui.components.update_dialog import (
release_history_state,
release_notes_preview,
update_version_heading,
)
class UpdateDialogTests(unittest.TestCase):
def test_release_notes_preview_shows_changes_without_build_footer(self) -> None:
preview = release_notes_preview(
"# Launcher 1.8.0\n\n"
"## Highlights\n\n"
"- Added channel-aware updates\n"
"- Improved rollback\n\n"
"---\n\nVersion: `1.8.0`\n"
)
self.assertIn("## Highlights", preview)
self.assertIn("- Improved rollback", preview)
self.assertNotIn("Version:", preview)
self.assertNotIn("Launcher 1.8.0", preview)
def test_release_notes_preview_is_bounded(self) -> None:
preview = release_notes_preview(
"\n".join(f"- Change {index}" for index in range(30))
)
self.assertIn("- Change 0", preview)
self.assertNotIn("- Change 20", preview)
self.assertTrue(preview.endswith("…"))
def test_current_version_heading_uses_running_launcher(self) -> None:
self.assertEqual(
update_version_heading(
"0.9.11",
"0.9.10",
"stable",
update_available=False,
),
"Current version: 0.9.11\nLatest Stable release: 0.9.10",
)
def test_history_distinguishes_running_and_installed_versions(self) -> None:
self.assertEqual(
release_history_state(
"active",
is_running=True,
local_active=False,
local_retained=False,
is_older=False,
),
"Current",
)
self.assertEqual(
release_history_state(
"active",
is_running=False,
local_active=True,
local_retained=True,
is_older=False,
),
"Installed",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from ptlinux.update_resolver import (
BLOCKED_STATUS,
PRERELEASE_CHANNEL,
STABLE_CHANNEL,
WITHDRAWN_STATUS,
ReleaseModelError,
ReleaseRecord,
SemanticVersion,
UpdateResolver,
previous_release_tag,
validate_channel_version,
validate_release_progression,
)
class SemanticVersionTests(unittest.TestCase):
def test_orders_beta_rc_final_and_multidigit_versions(self) -> None:
versions = [
SemanticVersion.parse(value)
for value in (
"1.9.0",
"1.9.0-rc.1",
"1.10.0",
"1.9.0-beta.2",
"1.9.0-beta.1",
"1.9.9",
)
]
self.assertEqual(
[str(value) for value in sorted(versions)],
[
"1.9.0-beta.1",
"1.9.0-beta.2",
"1.9.0-rc.1",
"1.9.0",
"1.9.9",
"1.10.0",
],
)
def test_rejects_unsupported_or_noncanonical_versions(self) -> None:
for value in (
"v1.2.3",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-alpha.1",
"1.2.3-beta",
"1.2.3+build",
):
with self.subTest(value=value):
with self.assertRaises(ReleaseModelError):
SemanticVersion.parse(value)
def test_rejects_channel_version_mismatches(self) -> None:
with self.assertRaisesRegex(ReleaseModelError, "belongs to prerelease"):
validate_channel_version(STABLE_CHANNEL, "1.9.0-beta.1")
with self.assertRaisesRegex(ReleaseModelError, "belongs to stable"):
validate_channel_version(PRERELEASE_CHANNEL, "1.9.0")
def test_selects_channel_specific_changelog_bases(self) -> None:
tags = ["not-a-release", "v1.7.4", "v1.9.0-beta.1", "v1.9.0-beta.2"]
self.assertEqual(
previous_release_tag(STABLE_CHANNEL, "1.8.0", tags),
"v1.7.4",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "1.9.0-rc.1", tags),
"v1.9.0-beta.2",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "2.0.0-beta.1", tags),
"v1.7.4",
)
def test_release_progression_cannot_move_a_channel_backwards(self) -> None:
validate_release_progression(
STABLE_CHANNEL,
"1.9.0",
["v1.8.0", "v2.0.0-beta.1"],
)
validate_release_progression(
PRERELEASE_CHANNEL,
"2.0.0-beta.2",
["v1.9.0", "v2.0.0-beta.1"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
STABLE_CHANNEL,
"1.8.0",
["v1.9.0"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
PRERELEASE_CHANNEL,
"1.9.0-rc.1",
["v2.0.0-beta.1"],
)
class UpdateResolverTests(unittest.TestCase):
def setUp(self) -> None:
self.resolver = UpdateResolver(
[
ReleaseRecord("1.7.4", STABLE_CHANNEL, tag="v1.7.4"),
ReleaseRecord("1.8.0", STABLE_CHANNEL, tag="v1.8.0"),
ReleaseRecord("1.9.0-beta.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-beta.2", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("2.0.0-beta.1", PRERELEASE_CHANNEL, WITHDRAWN_STATUS),
ReleaseRecord("2.0.0-beta.2", PRERELEASE_CHANNEL, BLOCKED_STATUS),
]
)
def test_stable_clients_never_receive_prereleases(self) -> None:
candidate = self.resolver.update_candidate("1.7.4", STABLE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.8.0")
def test_prerelease_clients_receive_highest_active_semantic_version(self) -> None:
candidate = self.resolver.update_candidate(
"1.9.0-beta.1",
PRERELEASE_CHANNEL,
)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0-rc.1")
def test_prerelease_client_transitions_to_the_final_stable_release(self) -> None:
resolver = UpdateResolver(
[
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0", STABLE_CHANNEL),
]
)
candidate = resolver.update_candidate("1.9.0-rc.1", PRERELEASE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0")
def test_switching_to_stable_does_not_downgrade(self) -> None:
self.assertIsNone(
self.resolver.update_candidate("1.9.0-beta.2", STABLE_CHANNEL)
)
def test_channel_aware_rollback_candidates_are_real_releases(self) -> None:
stable = self.resolver.rollback_candidates("1.9.0", STABLE_CHANNEL)
prerelease = self.resolver.rollback_candidates(
"1.9.0-rc.1",
PRERELEASE_CHANNEL,
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.9.0-beta.1", "1.8.0", "1.7.4"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import replace
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherUpdateCheck,
UpdateManifest,
_download_archive,
_record_launcher_operation,
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
)
def release_manifest(version: str, channel: str = "stable") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://updates.example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=f"https://updates.example.test/releases/tag/v{version}",
tag=f"v{version}",
manifest_sha256="b" * 64,
)
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str) -> None:
files = {
"release.json": json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
}
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
class UpdateTelemetryTests(unittest.TestCase):
def test_local_operation_history_is_bounded_to_thirty_records(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
for index in range(35):
self.assertTrue(
_record_launcher_operation(
paths,
from_version=f"1.0.{index}",
to_version=f"1.0.{index + 1}",
from_channel="stable",
to_channel="stable",
selected_channel="stable",
result="success",
rollback=False,
reason="automatic_update",
)
)
value = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)
self.assertEqual(len(value["operations"]), 30)
self.assertEqual(value["operations"][0]["from"], "1.0.5")
self.assertEqual(value["operations"][-1]["to"], "1.0.35")
def test_rollback_download_emits_success_and_failure_families(self) -> None:
content = b"authenticated rollback archive"
manifest = replace(
release_manifest("1.7.4"),
archive_size=len(content),
archive_sha256=hashlib.sha256(content).hexdigest(),
)
class Response(io.BytesIO):
def __init__(self, data: bytes) -> None:
super().__init__(data)
self.headers = {"Content-Length": str(len(data))}
def geturl(self) -> str:
return manifest.archive_url
def __enter__(self) -> Response:
return self
def __exit__(self, *_args: object) -> None:
self.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
metadata = {
"from_version": "1.8.0",
"to_version": "1.7.4",
"rollback": True,
}
telemetry = MagicMock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(content)),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
_download_archive(
manifest,
root / "success.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_success"],
)
telemetry.reset_mock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(b"x" * len(content))),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
self.assertRaisesRegex(RuntimeError, "SHA-256"),
):
_download_archive(
manifest,
root / "failure.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_failed"],
)
def test_discovery_emits_release_identity_without_release_notes(self) -> None:
telemetry = MagicMock()
manifest = release_manifest("1.8.0")
with (
patch("ptlinux.self_update._fetch_authenticated_manifest", return_value=manifest),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
check_for_launcher_update(
current_version="1.7.4",
manifest_url="https://updates.example.test/stable.json",
channel="stable",
)
call = telemetry.send_event.call_args
self.assertEqual(call.args[0], "update_release_discovered")
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertEqual(metadata["current_channel"], "stable")
self.assertEqual(metadata["target_channel"], "stable")
self.assertEqual(metadata["selected_update_channel"], "stable")
self.assertEqual(metadata["release_tag"], "v1.8.0")
self.assertEqual(metadata["manifest_version"], 1)
self.assertEqual(metadata["manifest_hash"], "b" * 64)
self.assertFalse(metadata["rollback"])
self.assertNotIn("notes", metadata)
def test_update_apply_emits_started_and_success_with_shared_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
manifest = release_manifest("1.8.0")
check = LauncherUpdateCheck(
current_version="1.7.4",
latest_version="1.8.0",
available=True,
supported=True,
reason="Launcher 1.8.0 is available.",
manifest_url="https://updates.example.test/stable.json",
manifest=manifest,
selected_channel="stable",
)
release = paths.launcher_releases / "1.8.0"
telemetry = MagicMock()
with (
patch("ptlinux.self_update.check_for_launcher_update", return_value=check),
patch("ptlinux.self_update._precheck_release_install"),
patch("ptlinux.self_update._download_archive"),
patch("ptlinux.self_update._stage_and_install_release", return_value=release),
patch("ptlinux.self_update._activate_release", return_value=None),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
result = install_launcher_update(paths, current_version="1.7.4")
self.assertTrue(result.changed)
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(events, ["update_apply_started", "update_apply_success"])
for call in telemetry.send_event.call_args_list:
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertFalse(metadata["rollback"])
def test_local_rollback_emits_requested_and_apply_lifecycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
telemetry = MagicMock()
with patch("ptlinux.self_update.current_telemetry", return_value=telemetry):
rollback_launcher_update(paths, reason="support_recommendation")
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(
events,
[
"rollback_requested",
"rollback_apply_started",
"rollback_apply_success",
],
)
metadata = telemetry.send_event.call_args_list[0].kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.8.0")
self.assertEqual(metadata["target_version"], "1.7.4")
self.assertEqual(metadata["from_version"], "1.8.0")
self.assertEqual(metadata["to_version"], "1.7.4")
self.assertEqual(metadata["from_channel"], "stable")
self.assertEqual(metadata["to_channel"], "stable")
self.assertEqual(metadata["reason"], "support_recommendation")
self.assertTrue(metadata["rollback"])
if __name__ == "__main__":
unittest.main()

1
scripts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Release automation modules."""

60
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$3"
cd -- "$PROJECT_ROOT"
ARGUMENTS=(
"${SCRIPT_ROOT}/build_release.py"
"$VERSION"
"$CHANNEL"
"$OUTPUT_DIRECTORY"
)
if [[ -n "${REPOSITORY_URL:-}" ]]
then
ARGUMENTS+=(--repository-url "$REPOSITORY_URL")
fi
if [[ -n "${RELEASE_TAG:-}" ]]
then
ARGUMENTS+=(--tag "$RELEASE_TAG")
fi
if [[ -n "${RELEASE_COMMIT:-}" ]]
then
ARGUMENTS+=(--commit "$RELEASE_COMMIT")
fi
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]
then
ARGUMENTS+=(--source-date-epoch "$SOURCE_DATE_EPOCH")
fi
if [[ -n "${RELEASE_NOTES_FILE:-}" ]]
then
ARGUMENTS+=(--release-notes "$RELEASE_NOTES_FILE")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
if [[ "${ALLOW_SOURCE_VERSION_OVERRIDE:-0}" == "1" ]]
then
ARGUMENTS+=(--allow-source-version-override)
fi
if [[ -n "${RELEASE_ARCHIVE_LAYOUT:-}" ]]
then
ARGUMENTS+=(--archive-layout "$RELEASE_ARCHIVE_LAYOUT")
fi
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

64
scripts/build_release.py Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Build one deterministic launcher release and its external metadata."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, build_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--repository-url")
parser.add_argument("--tag")
parser.add_argument("--commit")
parser.add_argument("--source-date-epoch", type=int)
parser.add_argument("--release-notes", type=Path)
parser.add_argument("--signing-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
parser.add_argument("--allow-source-version-override", action="store_true")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
return parser
def main() -> int:
arguments = build_parser().parse_args()
signing_key = arguments.signing_key
if signing_key is None and os.environ.get("PTLINUX_SIGNING_KEY"):
signing_key = Path(os.environ["PTLINUX_SIGNING_KEY"])
try:
artifacts = build_release(
arguments.version,
arguments.channel,
arguments.output_directory,
repository_url=arguments.repository_url,
tag=arguments.tag,
commit=arguments.commit,
source_date_epoch=arguments.source_date_epoch,
release_notes_file=arguments.release_notes,
signing_key=signing_key,
require_signature=arguments.require_signature,
allow_source_version_override=arguments.allow_source_version_override,
archive_layout=arguments.archive_layout,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release build failed: {exc}", file=sys.stderr)
return 1
for artifact in artifacts:
print(artifact)
return 0
if __name__ == "__main__":
raise SystemExit(main())

75
scripts/generate-manifest.py Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate the external schema-1 release manifest for an existing archive."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
generate_manifest,
load_config,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--channel", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--file-root", required=True, type=Path)
parser.add_argument("--archive", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--repository-url", required=True)
parser.add_argument("--published-at", required=True)
parser.add_argument("--release-title")
parser.add_argument("--release-summary-file", type=Path)
parser.add_argument("--release-notes-url")
parser.add_argument("--archive-layout", choices=("flat", "wrapped"))
parser.add_argument("--minimum-updater-version")
return parser
def main() -> int:
arguments = build_parser().parse_args()
release_notes = ""
if arguments.release_summary_file is not None:
try:
release_notes = arguments.release_summary_file.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
try:
generate_manifest(
arguments.output,
config=load_config(),
file_root=arguments.file_root,
archive=arguments.archive,
version=arguments.version,
channel=arguments.channel,
tag=arguments.tag,
commit=arguments.commit,
repository_url=arguments.repository_url,
release_timestamp=arguments.published_at,
release_notes=release_notes,
release_title=arguments.release_title,
release_notes_url=arguments.release_notes_url,
archive_layout=arguments.archive_layout,
minimum_updater_version=arguments.minimum_updater_version,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Manifest generation failed: {exc}", file=sys.stderr)
return 1
print(arguments.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())

261
scripts/generate_history.py Executable file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Generate signed-history source data from Gitea release API results."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from typing import Mapping
from urllib.parse import urlsplit
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_parse_timestamp,
load_config,
)
from ptlinux.update_resolver import (
SUPPORTED_RELEASE_STATUSES,
ReleaseModelError,
SemanticVersion,
)
def release_history(
releases: object,
generated_at: str,
statuses: Mapping[str, object] | None = None,
) -> dict[str, object]:
_parse_timestamp(generated_at)
if not isinstance(releases, list):
raise ReleaseToolError("Gitea releases response must be an array")
records: list[tuple[SemanticVersion, dict[str, object]]] = []
seen_versions: set[SemanticVersion] = set()
included_tags: set[str] = set()
for item in releases:
if not isinstance(item, dict) or item.get("draft") is True:
continue
tag = item.get("tag_name")
if not isinstance(tag, str):
continue
try:
version = SemanticVersion.from_tag(tag)
except ReleaseModelError:
continue
if version in seen_versions:
raise ReleaseToolError(f"Gitea returned duplicate release tag {tag}")
seen_versions.add(version)
if bool(item.get("prerelease")) != version.is_prerelease:
continue
assets = item.get("assets")
if not isinstance(assets, list):
continue
asset_urls: dict[str, str] = {}
for asset in assets:
if not isinstance(asset, dict):
continue
name = asset.get("name")
url = asset.get("browser_download_url")
if isinstance(name, str) and isinstance(url, str):
asset_urls[name] = url
manifest_url = asset_urls.get("manifest.json")
signature_url = asset_urls.get("manifest.json.sig")
release_date = item.get("published_at") or item.get("created_at")
release_notes_url = item.get("html_url")
if (
manifest_url is None
or signature_url is None
or not isinstance(release_date, str)
or not isinstance(release_notes_url, str)
or not all(
_https_url(url)
for url in (manifest_url, signature_url, release_notes_url)
)
):
continue
try:
_parse_timestamp(release_date)
except ReleaseToolError:
continue
release_id = item.get("id")
override = (statuses or {}).get(tag, "active")
if isinstance(override, dict):
status = override.get("status", "active")
config_schema = _positive_integer(
override.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
override.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
else:
status = override
config_schema = 1
minimum_config_schema = 1
if status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
title = item.get("name")
if not isinstance(title, str) or not title:
title = f"PristonTale EU Launcher {version}"
records.append(
(
version,
{
"channel": version.channel,
"compatibility": {
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
},
"manifest_signature_url": signature_url,
"manifest_url": manifest_url,
"release_date": release_date,
"release_id": (
release_id
if isinstance(release_id, int)
and not isinstance(release_id, bool)
and release_id > 0
else None
),
"release_notes_url": release_notes_url,
"status": status,
"tag": tag,
"title": title,
"version": str(version),
},
)
)
included_tags.add(tag)
unused_statuses = set(statuses or {}).difference(included_tags)
if unused_statuses:
raise ReleaseToolError(
"release status overrides do not match published, manifest-backed tags: "
+ ", ".join(sorted(unused_statuses))
)
records.sort(key=lambda entry: entry[0], reverse=True)
config = load_config()
return {
"application": config["application"],
"generated_at": generated_at,
"releases": [record for _version, record in records],
"schema": 1,
}
def load_release_statuses(path: Path) -> dict[str, object]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"invalid release status file {path}: {exc}") from exc
if not isinstance(value, dict) or value.get("schema") != 1:
raise ReleaseToolError("release status file must use schema 1")
releases = value.get("releases")
if not isinstance(releases, dict):
raise ReleaseToolError("release status file releases must be an object")
statuses: dict[str, object] = {}
for tag, status_value in releases.items():
if not isinstance(tag, str):
raise ReleaseToolError("release status tags must be strings")
try:
SemanticVersion.from_tag(tag)
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release status tag {tag!r}") from exc
status = status_value.get("status", "active") if isinstance(status_value, dict) else status_value
if not isinstance(status, str) or status not in SUPPORTED_RELEASE_STATUSES:
raise ReleaseToolError(f"invalid status for {tag}: {status!r}")
if isinstance(status_value, dict):
unknown = set(status_value).difference(
{"status", "config_schema", "minimum_config_schema"}
)
if unknown:
raise ReleaseToolError(
f"unknown release override fields for {tag}: {', '.join(sorted(unknown))}"
)
config_schema = _positive_integer(
status_value.get("config_schema", 1),
f"{tag} config_schema",
)
minimum_config_schema = _positive_integer(
status_value.get("minimum_config_schema", 1),
f"{tag} minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
f"minimum_config_schema exceeds config_schema for {tag}"
)
statuses[tag] = {
"status": status,
"config_schema": config_schema,
"minimum_config_schema": minimum_config_schema,
}
else:
statuses[tag] = status
return statuses
def _positive_integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ReleaseToolError(f"{label} must be a positive integer")
return value
def _https_url(value: str) -> bool:
parts = urlsplit(value)
return (
parts.scheme.casefold() == "https"
and bool(parts.netloc)
and parts.username is None
and parts.password is None
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--generated-at", required=True)
parser.add_argument("--status-file", type=Path)
arguments = parser.parse_args()
try:
releases = json.loads(arguments.input.read_text(encoding="utf-8"))
config = load_config()
status_file = arguments.status_file or (
PROJECT_ROOT / str(config["release_status_file"])
)
history = release_history(
releases,
arguments.generated_at,
load_release_statuses(status_file),
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
temporary = arguments.output.with_name(
f".{arguments.output.name}.{os.getpid()}.tmp"
)
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(history, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, arguments.output)
finally:
temporary.unlink(missing_ok=True)
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Release history generation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Build one signed channel view from authenticated release history metadata."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402
from ptlinux.update_resolver import ( # noqa: E402
ReleaseModelError,
ReleaseRecord,
UpdateResolver,
validate_channel,
)
from scripts.release_lib import ReleaseToolError # noqa: E402
def prepare_channel_metadata(
history: object,
channel: str,
manifest_bytes: bytes,
) -> tuple[dict[str, object], dict[str, object]]:
"""Return the selected history record and a status-aware manifest copy."""
try:
validate_channel(channel)
except ReleaseModelError as exc:
raise ReleaseToolError(str(exc)) from exc
if not isinstance(history, dict) or history.get("schema") != 1:
raise ReleaseToolError("release history must use schema 1")
releases = history.get("releases")
if not isinstance(releases, list):
raise ReleaseToolError("release history releases must be an array")
records: list[tuple[dict[str, object], ReleaseRecord]] = []
for value in releases:
if not isinstance(value, dict) or value.get("channel") != channel:
continue
try:
record = ReleaseRecord(
version=_string(value, "version"),
channel=_string(value, "channel"),
status=_string(value, "status"),
tag=_string(value, "tag"),
manifest_url=_string(value, "manifest_url"),
).validate()
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history record: {exc}") from exc
records.append((value, record))
if not records:
raise ReleaseToolError(f"release history has no {channel} releases")
try:
resolver = UpdateResolver(record for _value, record in records)
selected_record = resolver.latest_eligible(channel)
if selected_record is None:
selected_record = resolver.eligible_releases(channel, include_inactive=True)[0]
except ReleaseModelError as exc:
raise ReleaseToolError(f"invalid release history: {exc}") from exc
selected = next(
value for value, record in records if record.version == selected_record.version
)
try:
manifest = parse_update_manifest(manifest_bytes)
except LauncherUpdateError as exc:
raise ReleaseToolError(f"selected channel manifest is invalid: {exc}") from exc
for field, actual, expected in (
("version", manifest.version, selected_record.version),
("channel", manifest.channel, selected_record.channel),
("tag", manifest.tag, selected_record.tag),
):
if actual != expected:
raise ReleaseToolError(
f"selected manifest {field} {actual!r} does not match history {expected!r}"
)
try:
manifest_value = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ReleaseToolError(f"selected channel manifest is invalid JSON: {exc}") from exc
if not isinstance(manifest_value, dict):
raise ReleaseToolError("selected channel manifest must be an object")
manifest_value["status"] = selected_record.status
history_compatibility = selected.get("compatibility")
if not isinstance(history_compatibility, dict):
raise ReleaseToolError("selected release compatibility must be an object")
config_schema = _positive_integer(history_compatibility, "config_schema")
minimum_config_schema = _positive_integer(
history_compatibility,
"minimum_config_schema",
)
if minimum_config_schema > config_schema:
raise ReleaseToolError(
"selected release minimum_config_schema exceeds config_schema"
)
manifest_compatibility = manifest_value.get("compatibility")
if not isinstance(manifest_compatibility, dict):
raise ReleaseToolError("selected channel manifest compatibility is invalid")
manifest_compatibility["config_schema"] = config_schema
manifest_compatibility["minimum_config_schema"] = minimum_config_schema
return selected, manifest_value
def _string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise ReleaseToolError(f"release history field {key!r} must be a string")
return item
def _positive_integer(value: dict[str, object], key: str) -> int:
item = value.get(key)
if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
raise ReleaseToolError(
f"selected release compatibility {key!r} must be positive"
)
return item
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(0o644)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("history", type=Path)
parser.add_argument("channel", choices=("stable", "prerelease"))
parser.add_argument("manifest", type=Path)
parser.add_argument("output", type=Path)
arguments = parser.parse_args()
try:
history = json.loads(arguments.history.read_text(encoding="utf-8"))
selected, manifest = prepare_channel_metadata(
history,
arguments.channel,
arguments.manifest.read_bytes(),
)
_write_json(arguments.output, manifest)
print(_string(selected, "version"))
except (OSError, UnicodeError, json.JSONDecodeError, ReleaseToolError) as exc:
print(f"Channel metadata preparation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

351
scripts/publish-gitea-release.sh Executable file
View File

@@ -0,0 +1,351 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 4 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR RELEASE_NOTES\n' "$0" >&2
exit 2
fi
VERSION="$1"
CHANNEL="$2"
OUTPUT_DIRECTORY="$(realpath -- "$3")"
RELEASE_NOTES="$(realpath -- "$4")"
TAG="${RELEASE_TAG:-v${VERSION}}"
COMMIT="${RELEASE_COMMIT:?RELEASE_COMMIT is required}"
PRERELEASE="${RELEASE_PRERELEASE:?RELEASE_PRERELEASE is required}"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ARCHIVE_PREFIX="$(jq -er '.archive_prefix' "${SCRIPT_ROOT}/../release/config.json")"
ARCHIVE="${OUTPUT_DIRECTORY}/${ARCHIVE_PREFIX}-${VERSION}.tar.gz"
CHECKSUM="${ARCHIVE}.sha256"
MANIFEST="${OUTPUT_DIRECTORY}/manifest.json"
MANIFEST_SIGNATURE="${OUTPUT_DIRECTORY}/manifest.json.sig"
RELEASE_NOTES_ASSET="${OUTPUT_DIRECTORY}/release-notes.md"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
RELEASE_MODEL="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/../ptlinux/update_resolver.py" inspect-tag "$TAG"
)"
if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \
|| "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \
|| "$(jq -r '.prerelease' <<< "$RELEASE_MODEL")" != "$PRERELEASE" ]]
then
printf 'Release tag, version, channel, and prerelease flag are inconsistent.\n' >&2
exit 1
fi
if [[ ! "$COMMIT" =~ ^[0-9a-f]{40,64}$ ]]
then
printf 'Release commit is not a lowercase Git object ID: %s\n' "$COMMIT" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
if [[ ! -s "$REQUIRED_ARTIFACT" ]]
then
printf 'Required release artifact is missing or empty: %s\n' "$REQUIRED_ARTIFACT" >&2
exit 1
fi
done
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
EXISTING_RESPONSE="${RESPONSE_DIRECTORY}/existing.json"
EXISTING_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$EXISTING_RESPONSE")"
case "$EXISTING_STATUS" in
404)
;;
200)
printf 'Release %s already exists. Published releases are immutable.\n' "$TAG" >&2
exit 1
;;
*)
printf 'Could not check existing release %s: HTTP %s\n' "$TAG" "$EXISTING_STATUS" >&2
jq -r '.message // empty' "$EXISTING_RESPONSE" >&2 || true
exit 1
;;
esac
RELEASE_PAYLOAD="${RESPONSE_DIRECTORY}/release-payload.json"
jq -n \
--rawfile body "$RELEASE_NOTES" \
--arg name "PristonTale EU Launcher ${VERSION}" \
--arg tag_name "$TAG" \
--arg target_commitish "$COMMIT" \
--argjson prerelease "$PRERELEASE" \
'{body:$body,draft:false,name:$name,prerelease:$prerelease,tag_name:$tag_name,target_commitish:$target_commitish}' \
> "$RELEASE_PAYLOAD"
CREATE_RESPONSE="${RESPONSE_DIRECTORY}/created.json"
CREATE_STATUS="$(
api_request POST "${API_ROOT}/releases" "$CREATE_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${RELEASE_PAYLOAD}"
)"
if [[ "$CREATE_STATUS" != "201" ]]
then
printf 'Gitea release creation failed: HTTP %s\n' "$CREATE_STATUS" >&2
jq -r '.message // empty' "$CREATE_RESPONSE" >&2 || true
exit 1
fi
RELEASE_ID="$(jq -er '.id | select(type == "number" and . > 0)' "$CREATE_RESPONSE")"
upload_asset()
{
local FILE="$1"
local NAME
local RESPONSE
local STATUS
NAME="$(basename -- "$FILE")"
RESPONSE="${RESPONSE_DIRECTORY}/asset-${NAME}.json"
STATUS="$(
api_request POST "${API_ROOT}/releases/${RELEASE_ID}/assets?name=${NAME}" "$RESPONSE" \
--form "attachment=@${FILE};type=application/octet-stream"
)"
if [[ "$STATUS" != "201" ]]
then
printf 'Gitea asset upload failed for %s: HTTP %s\n' "$NAME" "$STATUS" >&2
jq -r '.message // empty' "$RESPONSE" >&2 || true
exit 1
fi
if [[ "$(jq -r '.name // empty' "$RESPONSE")" != "$NAME" ]]
then
printf 'Gitea returned the wrong attachment name for %s.\n' "$NAME" >&2
exit 1
fi
}
upload_asset "$ARCHIVE"
upload_asset "$CHECKSUM"
upload_asset "$MANIFEST"
upload_asset "$MANIFEST_SIGNATURE"
upload_asset "$RELEASE_NOTES_ASSET"
VERIFY_RESPONSE="${RESPONSE_DIRECTORY}/verified.json"
VERIFY_STATUS="$(api_request GET "${API_ROOT}/releases/tags/${TAG}" "$VERIFY_RESPONSE")"
if [[ "$VERIFY_STATUS" != "200" ]]
then
printf 'Could not re-query published release %s: HTTP %s\n' "$TAG" "$VERIFY_STATUS" >&2
exit 1
fi
if [[ "$(jq -r '.prerelease' "$VERIFY_RESPONSE")" != "$PRERELEASE" ]]
then
printf 'Published release prerelease flag does not match channel %s.\n' "$CHANNEL" >&2
exit 1
fi
if [[ "$(jq -r '.tag_name // empty' "$VERIFY_RESPONSE")" != "$TAG" \
|| "$(jq -r '.draft' "$VERIFY_RESPONSE")" != "false" \
|| "$(jq -r '.target_commitish // empty' "$VERIFY_RESPONSE")" != "$COMMIT" ]]
then
printf 'Published release identity or target commit does not match %s.\n' "$TAG" >&2
exit 1
fi
for REQUIRED_ARTIFACT in \
"$ARCHIVE" \
"$CHECKSUM" \
"$MANIFEST" \
"$MANIFEST_SIGNATURE" \
"$RELEASE_NOTES_ASSET"
do
ASSET_NAME="$(basename -- "$REQUIRED_ARTIFACT")"
EXPECTED_SIZE="$(stat -c '%s' -- "$REQUIRED_ARTIFACT")"
ACTUAL_SIZE="$(
jq -er --arg name "$ASSET_NAME" \
'.assets[] | select(.name == $name) | .size' "$VERIFY_RESPONSE"
)"
if [[ "$ACTUAL_SIZE" != "$EXPECTED_SIZE" ]]
then
printf 'Published asset size mismatch for %s: expected %s, got %s.\n' \
"$ASSET_NAME" "$EXPECTED_SIZE" "$ACTUAL_SIZE" >&2
exit 1
fi
done
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
PUBLISHED_AT="$(jq -er '.published_at' "$VERIFY_RESPONSE")"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$PUBLISHED_AT"
if ! jq -e \
--arg tag "$TAG" \
--arg channel "$CHANNEL" \
'.releases[] | select(.tag == $tag and .channel == $channel and .status == "active")' \
"$HISTORY" >/dev/null
then
printf 'Published release %s is absent or inactive in generated history.\n' "$TAG" >&2
exit 1
fi
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_CONTENT="$(base64 -w 0 -- "$MANIFEST")"
CHANNEL_SIGNATURE_CONTENT="$(base64 -w 0 -- "$MANIFEST_SIGNATURE")"
HISTORY_CONTENT="$(base64 -w 0 -- "$HISTORY")"
HISTORY_SIGNATURE_CONTENT="$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
CHANNEL_PATH="updates/${CHANNEL}.json"
CHANNEL_SIGNATURE_PATH="updates/${CHANNEL}.json.sig"
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
CHANNEL_OPERATION="$(file_operation "$CHANNEL_PATH" "$CHANNEL_CONTENT")"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "$CHANNEL_SIGNATURE_PATH" "$CHANNEL_SIGNATURE_CONTENT"
)"
HISTORY_OPERATION="$(file_operation updates/history.json "$HISTORY_CONTENT")"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$HISTORY_SIGNATURE_CONTENT"
)"
CHANNEL_PAYLOAD="${RESPONSE_DIRECTORY}/channel-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message "Update ${CHANNEL} channel to ${VERSION}" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson channel_signature "$CHANNEL_SIGNATURE_OPERATION" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'{branch:$branch,message:$message,files:[$channel,$channel_signature,$history,$history_signature]}' \
> "$CHANNEL_PAYLOAD"
CHANNEL_RESPONSE="${RESPONSE_DIRECTORY}/channel-response.json"
CHANNEL_STATUS="$(
api_request POST "${API_ROOT}/contents" "$CHANNEL_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${CHANNEL_PAYLOAD}"
)"
if [[ "$CHANNEL_STATUS" != "201" ]]
then
printf 'Atomic channel/history metadata update failed: HTTP %s\n' "$CHANNEL_STATUS" >&2
jq -r '.message // empty' "$CHANNEL_RESPONSE" >&2 || true
exit 1
fi
ARCHIVE_SHA256="$(cut -d ' ' -f 1 -- "$CHECKSUM")"
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published release\n\n'
printf '| Field | Value |\n|---|---|\n'
printf '| Release | PristonTale EU Launcher %s |\n' "$VERSION"
printf '| Channel | `%s` |\n' "$CHANNEL"
printf '| Tag | `%s` |\n' "$TAG"
printf '| Commit | `%s` |\n' "$COMMIT"
printf '| Archive | `%s` |\n' "$(basename -- "$ARCHIVE")"
printf '| SHA-256 | `%s` |\n' "$ARCHIVE_SHA256"
printf '| Manifest | `manifest.json` |\n'
printf '| Status | Published |\n'
} >> "$SUMMARY_FILE"
printf 'Published %s release %s (%s) with verified assets and channel metadata.\n' \
"$CHANNEL" "$TAG" "${SERVER_URL%/}/${REPOSITORY}/releases/tag/${TAG}"

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]
then
printf 'Usage: %s OUTPUT_DIR\n' "$0" >&2
exit 2
fi
OUTPUT_DIRECTORY="$(realpath -m -- "$1")"
API_URL="${GITHUB_API_URL:?GITHUB_API_URL is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
SERVER_URL="${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
TOKEN="${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
STATUS_FILE="${RELEASE_STATUS_FILE:-${SCRIPT_ROOT}/../release/statuses.json}"
API_ROOT="${API_URL%/}/repos/${REPOSITORY}"
RESPONSE_DIRECTORY="$(mktemp -d)"
cleanup()
{
rm -rf -- "$RESPONSE_DIRECTORY"
}
trap cleanup EXIT
mkdir -p -- "$OUTPUT_DIRECTORY"
api_request()
{
local METHOD="$1"
local URL="$2"
local OUTPUT="$3"
shift 3
curl \
--silent \
--show-error \
--connect-timeout 20 \
--max-time 180 \
--request "$METHOD" \
--header "Authorization: token ${TOKEN}" \
--header 'Accept: application/json' \
--output "$OUTPUT" \
--write-out '%{http_code}' \
"$@" \
"$URL"
}
fetch_all_releases()
{
local OUTPUT="$1"
local PAGE=1
local PAGE_COUNT
local PAGE_FILE
local STATUS
local COMBINED
printf '%s\n' '[]' > "$OUTPUT"
while (( PAGE <= 100 ))
do
PAGE_FILE="${RESPONSE_DIRECTORY}/releases-page-${PAGE}.json"
STATUS="$(
api_request GET "${API_ROOT}/releases?limit=100&page=${PAGE}" "$PAGE_FILE"
)"
if [[ "$STATUS" != "200" ]]
then
printf 'Could not fetch release history page %s: HTTP %s\n' \
"$PAGE" "$STATUS" >&2
exit 1
fi
if ! PAGE_COUNT="$(jq -er 'if type == "array" then length else error("not an array") end' "$PAGE_FILE")"
then
printf 'Gitea release history page %s is not an array.\n' "$PAGE" >&2
exit 1
fi
COMBINED="${RESPONSE_DIRECTORY}/releases-combined-${PAGE}.json"
jq -s '.[0] + .[1]' "$OUTPUT" "$PAGE_FILE" > "$COMBINED"
mv -- "$COMBINED" "$OUTPUT"
if (( PAGE_COUNT < 100 ))
then
return
fi
((PAGE += 1))
done
printf '%s\n' 'Gitea release history exceeded the 10,000-release safety limit.' >&2
exit 1
}
RELEASES_RESPONSE="${RESPONSE_DIRECTORY}/releases.json"
fetch_all_releases "$RELEASES_RESPONSE"
GENERATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
HISTORY="${OUTPUT_DIRECTORY}/history.json"
HISTORY_SIGNATURE="${OUTPUT_DIRECTORY}/history.json.sig"
"$PYTHON_BIN" "${SCRIPT_ROOT}/generate_history.py" \
"$RELEASES_RESPONSE" "$HISTORY" --generated-at "$GENERATED_AT" \
--status-file "$STATUS_FILE"
SIGNING_ARGUMENTS=(
"$HISTORY"
"$HISTORY_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" "${SIGNING_ARGUMENTS[@]}"
CHANNEL_DIRECTORY="${RESPONSE_DIRECTORY}/channels"
mkdir -p -- "$CHANNEL_DIRECTORY"
CHANNELS_TO_PUBLISH=()
for CHANNEL in stable prerelease
do
RECORD="$(
jq -cer --arg channel "$CHANNEL" \
'([.releases[] | select(.channel == $channel)]
| (map(select(.status == "active"))[0] // .[0]))' \
"$HISTORY" 2>/dev/null || true
)"
if [[ -z "$RECORD" || "$RECORD" == "null" ]]
then
continue
fi
MANIFEST_URL="$(jq -er '.manifest_url' <<< "$RECORD")"
MANIFEST_SIGNATURE_URL="$(jq -er '.manifest_signature_url' <<< "$RECORD")"
for ASSET_URL in "$MANIFEST_URL" "$MANIFEST_SIGNATURE_URL"
do
case "$ASSET_URL" in
"${SERVER_URL%/}/"*)
;;
*)
printf 'Refusing to send release credentials to untrusted URL: %s\n' \
"$ASSET_URL" >&2
exit 1
;;
esac
done
SOURCE_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json"
SOURCE_SIGNATURE="${CHANNEL_DIRECTORY}/${CHANNEL}-source.json.sig"
MANIFEST_STATUS="$(api_request GET "$MANIFEST_URL" "$SOURCE_MANIFEST")"
if [[ "$MANIFEST_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest: HTTP %s\n' \
"$CHANNEL" "$MANIFEST_STATUS" >&2
exit 1
fi
SIGNATURE_STATUS="$(
api_request GET "$MANIFEST_SIGNATURE_URL" "$SOURCE_SIGNATURE"
)"
if [[ "$SIGNATURE_STATUS" != "200" ]]
then
printf 'Could not fetch selected %s manifest signature: HTTP %s\n' \
"$CHANNEL" "$SIGNATURE_STATUS" >&2
exit 1
fi
VERIFICATION_ARGUMENTS=("$SOURCE_MANIFEST" "$SOURCE_SIGNATURE")
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
VERIFICATION_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/verify_metadata.py" \
"${VERIFICATION_ARGUMENTS[@]}"
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_VERSION="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/prepare_channel_metadata.py" \
"$HISTORY" "$CHANNEL" "$SOURCE_MANIFEST" "$CHANNEL_MANIFEST"
)"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_SIGNING_ARGUMENTS=(
"$CHANNEL_MANIFEST"
"$CHANNEL_SIGNATURE"
"${PTLINUX_SIGNING_KEY:?PTLINUX_SIGNING_KEY is required}"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
CHANNEL_SIGNING_ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
"$PYTHON_BIN" "${SCRIPT_ROOT}/sign_metadata.py" \
"${CHANNEL_SIGNING_ARGUMENTS[@]}"
printf '%s\n' "$CHANNEL_VERSION" > "${CHANNEL_DIRECTORY}/${CHANNEL}.version"
CHANNELS_TO_PUBLISH+=("$CHANNEL")
done
file_operation()
{
local PATH_VALUE="$1"
local CONTENT_VALUE="$2"
local RESPONSE="${RESPONSE_DIRECTORY}/content-$(basename -- "$PATH_VALUE").json"
local STATUS
STATUS="$(api_request GET "${API_ROOT}/contents/${PATH_VALUE}?ref=${RELEASE_BRANCH}" "$RESPONSE")"
case "$STATUS" in
200)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
--arg sha "$(jq -er '.sha' "$RESPONSE")" \
'{content:$content,operation:"update",path:$path,sha:$sha}'
;;
404)
jq -cn \
--arg content "$CONTENT_VALUE" \
--arg path "$PATH_VALUE" \
'{content:$content,operation:"create",path:$path}'
;;
*)
printf 'Could not inspect repository metadata path %s: HTTP %s\n' \
"$PATH_VALUE" "$STATUS" >&2
exit 1
;;
esac
}
OPERATIONS='[]'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
CHANNEL_MANIFEST="${CHANNEL_DIRECTORY}/${CHANNEL}.json"
CHANNEL_SIGNATURE="${CHANNEL_MANIFEST}.sig"
CHANNEL_OPERATION="$(
file_operation "updates/${CHANNEL}.json" "$(base64 -w 0 -- "$CHANNEL_MANIFEST")"
)"
CHANNEL_SIGNATURE_OPERATION="$(
file_operation "updates/${CHANNEL}.json.sig" \
"$(base64 -w 0 -- "$CHANNEL_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson channel "$CHANNEL_OPERATION" \
--argjson signature "$CHANNEL_SIGNATURE_OPERATION" \
'$operations + [$channel, $signature]'
)"
done
HISTORY_OPERATION="$(
file_operation updates/history.json "$(base64 -w 0 -- "$HISTORY")"
)"
HISTORY_SIGNATURE_OPERATION="$(
file_operation updates/history.json.sig "$(base64 -w 0 -- "$HISTORY_SIGNATURE")"
)"
OPERATIONS="$(
jq -cn \
--argjson operations "$OPERATIONS" \
--argjson history "$HISTORY_OPERATION" \
--argjson history_signature "$HISTORY_SIGNATURE_OPERATION" \
'$operations + [$history, $history_signature]'
)"
PAYLOAD="${RESPONSE_DIRECTORY}/history-payload.json"
jq -n \
--arg branch "$RELEASE_BRANCH" \
--arg message 'Publish signed launcher release status' \
--argjson files "$OPERATIONS" \
'{branch:$branch,message:$message,files:$files}' \
> "$PAYLOAD"
PUBLISH_RESPONSE="${RESPONSE_DIRECTORY}/history-publish.json"
PUBLISH_STATUS="$(
api_request POST "${API_ROOT}/contents" "$PUBLISH_RESPONSE" \
--header 'Content-Type: application/json' \
--data-binary "@${PAYLOAD}"
)"
if [[ "$PUBLISH_STATUS" != "201" ]]
then
printf 'Signed release-history publication failed: HTTP %s\n' "$PUBLISH_STATUS" >&2
jq -r '.message // empty' "$PUBLISH_RESPONSE" >&2 || true
exit 1
fi
SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
printf '## Published launcher release status\n\n'
printf -- '- Generated: `%s`\n' "$GENERATED_AT"
printf -- '- History: `updates/history.json`\n'
printf -- '- Signature: `updates/history.json.sig`\n'
for CHANNEL in "${CHANNELS_TO_PUBLISH[@]}"
do
printf -- '- %s channel: `%s`\n' \
"$CHANNEL" "$(cat -- "${CHANNEL_DIRECTORY}/${CHANNEL}.version")"
done
} >> "$SUMMARY_FILE"
printf 'Published signed launcher history and status-aware channel metadata.\n'

355
scripts/release.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/usr/bin/env bash
set -euo pipefail
usage()
{
cat <<'EOF'
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
Build a verified local release package in dist/vVERSION/. This command does not
commit, tag, push, create a Gitea release, or upload files.
Channels:
stable
prerelease (aliases: pre, beta)
Options:
--description TEXT Developer-written Markdown release description
--description-file PATH Read developer-written Markdown from PATH
--no-auto-changelog Omit commit-derived change notes
--changelog-only Write and display release notes without building
--editor Edit the final notes with ${EDITOR:-nano}
--dry-run Build and verify in a temporary directory only
--skip-tests Skip configured local preflight checks
--signing-key PATH Use PATH instead of the local release signing key
-h, --help Show this help
EOF
}
if [[ $# -lt 1 ]]
then
usage >&2
exit 2
fi
if [[ "$1" == "-h" || "$1" == "--help" ]]
then
usage
exit 0
fi
if [[ $# -lt 2 ]]
then
usage >&2
exit 2
fi
CHANNEL_INPUT="$1"
VERSION="$2"
shift 2
case "$CHANNEL_INPUT" in
stable)
CHANNEL="stable"
;;
prerelease|pre|beta)
CHANNEL="prerelease"
;;
*)
printf 'Unsupported release channel: %s\n' "$CHANNEL_INPUT" >&2
exit 2
;;
esac
DESCRIPTION=""
DESCRIPTION_FILE=""
AUTO_CHANGELOG=1
CHANGELOG_ONLY=0
OPEN_EDITOR=0
DRY_RUN=0
SKIP_TESTS=0
SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}"
while [[ $# -gt 0 ]]
do
case "$1" in
--description)
[[ $# -ge 2 ]] || { printf '%s\n' '--description requires text.' >&2; exit 2; }
DESCRIPTION="$2"
shift 2
;;
--description-file)
[[ $# -ge 2 ]] || { printf '%s\n' '--description-file requires a path.' >&2; exit 2; }
DESCRIPTION_FILE="$2"
shift 2
;;
--no-auto-changelog)
AUTO_CHANGELOG=0
shift
;;
--changelog-only)
CHANGELOG_ONLY=1
shift
;;
--editor)
OPEN_EDITOR=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-tests)
SKIP_TESTS=1
shift
;;
--signing-key)
[[ $# -ge 2 ]] || { printf '%s\n' '--signing-key requires a path.' >&2; exit 2; }
SIGNING_KEY="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown release option: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -n "$DESCRIPTION" && -n "$DESCRIPTION_FILE" ]]
then
printf '%s\n' 'Use either --description or --description-file, not both.' >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
CONFIG_PATH="${PROJECT_ROOT}/release/config.json"
if [[ -n "${PTLINUX_PYTHON:-}" ]]
then
PYTHON_BIN="$PTLINUX_PYTHON"
elif [[ -x "${PROJECT_ROOT}/.venv/bin/python" ]]
then
PYTHON_BIN="${PROJECT_ROOT}/.venv/bin/python"
else
PYTHON_BIN="python3"
fi
if [[ "$(git -C "$PROJECT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" != "$PROJECT_ROOT" ]]
then
printf 'Release command is not inside the expected Git repository: %s\n' "$PROJECT_ROOT" >&2
exit 1
fi
cd -- "$PROJECT_ROOT"
"$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION"
TAG="v${VERSION}"
COMMIT="$(git rev-parse HEAD)"
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)"
if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]
then
printf '%s\n' \
"Warning: packaging the current working tree on top of source commit ${COMMIT}." \
'The signed per-file manifest records the exact packaged content.' >&2
fi
if [[ -n "$DESCRIPTION_FILE" && ! -f "$DESCRIPTION_FILE" ]]
then
printf 'Release description file does not exist: %s\n' "$DESCRIPTION_FILE" >&2
exit 1
fi
TEMPORARY_ROOT="$(mktemp -d)"
cleanup()
{
rm -rf -- "$TEMPORARY_ROOT"
}
trap cleanup EXIT
NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md"
if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]]
then
printf '%s\n' 'Release description' '-------------------' '' \
'Enter release notes. Finish with Ctrl+D.' ''
DESCRIPTION_FILE="${TEMPORARY_ROOT}/description.md"
cat < /dev/tty > "$DESCRIPTION_FILE"
fi
{
printf '# PristonTale EU Launcher %s\n\n' "$VERSION"
if [[ -n "$DESCRIPTION" ]]
then
printf '%s\n\n' "$DESCRIPTION"
elif [[ -n "$DESCRIPTION_FILE" ]]
then
cat -- "$DESCRIPTION_FILE"
if [[ -s "$DESCRIPTION_FILE" && "$(tail -c 1 -- "$DESCRIPTION_FILE" | wc -l)" -eq 0 ]]
then
printf '\n'
fi
printf '\n'
fi
if [[ "$AUTO_CHANGELOG" -eq 1 ]]
then
mapfile -t RELEASE_TAGS < <(git tag --merged HEAD)
PREVIOUS_TAG="$(
"$PYTHON_BIN" -m ptlinux.update_resolver previous-tag \
"$CHANNEL" "$VERSION" "${RELEASE_TAGS[@]}"
)"
printf '## Changes\n\n'
if [[ -n "$PREVIOUS_TAG" ]]
then
CHANGELOG_RANGE="${PREVIOUS_TAG}..HEAD"
else
CHANGELOG_RANGE="HEAD"
fi
CHANGELOG="$(git log "$CHANGELOG_RANGE" --no-merges --pretty=format:'- %s (`%h`)')"
if [[ -n "$CHANGELOG" ]]
then
printf '%s\n' "$CHANGELOG"
else
printf '%s\n' '- No commits since the previous release.'
fi
printf '\n'
fi
printf '%s\n' '---' '' \
"Version: \`${VERSION}\` " \
"Channel: \`${CHANNEL}\` " \
"Tag: \`${TAG}\` " \
"Source commit: \`${COMMIT}\`"
} > "$NOTES_PATH"
if [[ "$OPEN_EDITOR" -eq 1 ]]
then
EDITOR_COMMAND="${EDITOR:-nano}"
"$EDITOR_COMMAND" "$NOTES_PATH"
fi
printf '%s\n' '--------------------------------------------------' 'Release Notes' \
'--------------------------------------------------'
cat -- "$NOTES_PATH"
printf '%s\n' '--------------------------------------------------'
if [[ "$CHANGELOG_ONLY" -eq 1 ]]
then
NOTES_OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
mkdir -p -- "$NOTES_OUTPUT_DIRECTORY"
cp -- "$NOTES_PATH" "${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
printf 'Release notes written to %s\n' \
"${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
exit 0
fi
if [[ "$SKIP_TESTS" -ne 1 ]]
then
mapfile -t PREFLIGHT_COMMANDS < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1],encoding="utf-8")); print("\n".join(value["preflight"]))' \
"$CONFIG_PATH"
)
for PREFLIGHT_COMMAND in "${PREFLIGHT_COMMANDS[@]}"
do
printf 'Preflight: %s\n' "$PREFLIGHT_COMMAND"
PTLINUX_PYTHON="$PYTHON_BIN" bash -o pipefail -c "$PREFLIGHT_COMMAND" \
< /dev/null
done
else
printf '%s\n' 'Skipping local preflight checks by request.'
fi
if [[ -z "$SIGNING_KEY" ]]
then
SIGNING_KEY="${PROJECT_ROOT}/release-keys/launcher-update-private.pem"
fi
if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]]
then
printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2
printf '%s\n' 'Use --signing-key PATH or set PTLINUX_SIGNING_KEY.' >&2
exit 1
fi
if [[ "$DRY_RUN" -eq 1 ]]
then
OUTPUT_DIRECTORY="${TEMPORARY_ROOT}/dist/${TAG}"
else
OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
fi
SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
RELEASE_COMMIT="$COMMIT" \
RELEASE_TAG="$TAG" \
RELEASE_NOTES_FILE="$NOTES_PATH" \
PTLINUX_PYTHON="$PYTHON_BIN" \
PTLINUX_SIGNING_KEY="$SIGNING_KEY" \
REQUIRE_RELEASE_SIGNATURE=1 \
ALLOW_SOURCE_VERSION_OVERRIDE=1 \
"${SCRIPT_ROOT}/build-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
PTLINUX_PYTHON="$PYTHON_BIN" \
REQUIRE_RELEASE_SIGNATURE=1 \
"${SCRIPT_ROOT}/verify-release.sh" "$VERSION" "$CHANNEL" "$OUTPUT_DIRECTORY"
if [[ "$DRY_RUN" -eq 1 ]]
then
printf '%s\n' \
'Dry run complete. The release built and verified in a temporary directory; dist/ was not changed.'
exit 0
fi
mkdir -p -- "${OUTPUT_DIRECTORY}/channel"
cp -- "${OUTPUT_DIRECTORY}/manifest.json" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json"
cp -- "${OUTPUT_DIRECTORY}/manifest.json.sig" \
"${OUTPUT_DIRECTORY}/channel/${CHANNEL}.json.sig"
mapfile -t PACKAGE_METADATA < <(
"$PYTHON_BIN" -c \
'import json,sys; value=json.load(open(sys.argv[1], encoding="utf-8")); archive=value["archive"]; print(archive["layout"]); print(value["minimum_updater_version"]); print(archive["size"]); print(archive["sha256"]); print(archive["url"])' \
"${OUTPUT_DIRECTORY}/manifest.json"
)
ARCHIVE_LAYOUT="${PACKAGE_METADATA[0]}"
MINIMUM_UPDATER_VERSION="${PACKAGE_METADATA[1]}"
ARCHIVE_SIZE="${PACKAGE_METADATA[2]}"
ARCHIVE_SHA256="${PACKAGE_METADATA[3]}"
ARCHIVE_URL="${PACKAGE_METADATA[4]}"
if [[ "$CHANNEL" == "prerelease" ]]
then
PRERELEASE_INSTRUCTION='select the Gitea "Pre-release" checkbox'
else
PRERELEASE_INSTRUCTION='leave the Gitea "Pre-release" checkbox clear'
fi
printf '\nManual Gitea release package is ready in %s\n' "$OUTPUT_DIRECTORY"
printf 'Create tag/release: %s\n' "$TAG"
printf 'Release type: %s; %s.\n' "$CHANNEL" "$PRERELEASE_INSTRUCTION"
printf 'Archive layout: %s.\n' "$ARCHIVE_LAYOUT"
printf 'Minimum updater: %s.\n' "$MINIMUM_UPDATER_VERSION"
printf 'Archive size: %s bytes.\n' "$ARCHIVE_SIZE"
printf 'Archive SHA256: %s\n' "$ARCHIVE_SHA256"
printf 'Signed archive URL: %s\n' "$ARCHIVE_URL"
if [[ "$ARCHIVE_LAYOUT" == "wrapped" ]]
then
printf '%s\n' \
'Compatibility: legacy bridge archive; older launchers can install it automatically.'
else
printf '%s\n' \
'Compatibility: flat archive; launchers older than the minimum require a bridge release or installer update.'
fi
printf '%s\n' 'Upload these five files:' \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz" \
" ${OUTPUT_DIRECTORY}/PristonTale-EU-${VERSION}.tar.gz.sha256" \
" ${OUTPUT_DIRECTORY}/manifest.json" \
" ${OUTPUT_DIRECTORY}/manifest.json.sig" \
" ${OUTPUT_DIRECTORY}/release-notes.md"
printf '%s\n' \
'Wait for every upload to finish and confirm the archive has the exact nonzero size above.' \
'Publish the channel files only after all five Gitea attachments are present.'
printf 'Channel files ready for a later repository update: %s/channel/%s.json{,.sig}\n' \
"$OUTPUT_DIRECTORY" "$CHANNEL"
printf '%s\n' 'No commit, tag, push, Gitea release, or upload was performed.'

1230
scripts/release_lib.py Normal file

File diff suppressed because it is too large Load Diff

83
scripts/set_version.py Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Update the authoritative launcher version and its project metadata mirror."""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, load_config # noqa: E402
from ptlinux.update_resolver import SemanticVersion
SOURCE_PATTERN = re.compile(
r'(?m)^__version__ = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
PROJECT_PATTERN = re.compile(
r'(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?"$'
)
def replace_once(path: Path, pattern: re.Pattern[str], replacement: str) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ReleaseToolError(f"cannot read version file {path}: {exc}") from exc
updated, count = pattern.subn(replacement, original)
if count != 1:
raise ReleaseToolError(
f"expected exactly one version declaration in {path}, found {count}"
)
if updated == original:
return
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
temporary.chmod(path.stat().st_mode & 0o777)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
arguments = parser.parse_args()
try:
SemanticVersion.parse(arguments.version)
config = load_config()
version_source = config.get("version_source")
if not isinstance(version_source, str):
raise ReleaseToolError("version_source must be configured")
replace_once(
PROJECT_ROOT / version_source,
SOURCE_PATTERN,
f'__version__ = "{arguments.version}"',
)
mirrors = config.get("version_mirrors")
if not isinstance(mirrors, list) or not all(isinstance(item, str) for item in mirrors):
raise ReleaseToolError("version_mirrors must be configured")
for mirror in mirrors:
replace_once(
PROJECT_ROOT / mirror,
PROJECT_PATTERN,
f'version = "{arguments.version}"',
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Version update failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

46
scripts/sign_metadata.py Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Sign release metadata and verify it against the launcher's public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
sign_file,
verify_signature,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument("private_key", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
sign_file(arguments.source, arguments.signature, arguments.private_key)
verify_signature(
arguments.source,
arguments.signature,
arguments.public_key,
)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata signing failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Confirm that a Git remote points at the configured release repository."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
from urllib.parse import unquote, urlsplit
class RemoteValidationError(ValueError):
pass
def repository_identity(value: str) -> tuple[str, str]:
if not value or any(character in value for character in "\r\n\0"):
raise RemoteValidationError("repository URL is empty or malformed")
scp = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", value)
if scp is not None and "://" not in value:
return _remote_identity(scp.group(1), scp.group(2))
parts = urlsplit(value)
if parts.scheme in {"https", "http", "ssh", "git"}:
hostname = parts.hostname
if hostname is None:
raise RemoteValidationError("repository URL has no host")
host = hostname.casefold()
if parts.port is not None:
host += f":{parts.port}"
return _remote_identity(host, unquote(parts.path))
if parts.scheme == "file":
return "file", str(Path(unquote(parts.path)).expanduser().resolve())
if parts.scheme:
raise RemoteValidationError(f"unsupported repository URL scheme: {parts.scheme}")
return "file", str(Path(value).expanduser().resolve())
def remote_matches(actual: str, expected: list[str]) -> bool:
actual_identity = repository_identity(actual)
return any(repository_identity(candidate) == actual_identity for candidate in expected)
def _remote_identity(host: str, path: str) -> tuple[str, str]:
normalized = path.strip("/")
if normalized.endswith(".git"):
normalized = normalized[:-4]
if not host or not normalized or normalized.startswith("../"):
raise RemoteValidationError("repository URL has an invalid host or path")
return host.casefold(), normalized
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("actual")
parser.add_argument("expected", nargs="+")
arguments = parser.parse_args()
try:
if not remote_matches(arguments.actual, arguments.expected):
raise RemoteValidationError(
"configured Git remote does not point at the expected release repository"
)
except RemoteValidationError as exc:
print(f"Release remote validation failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

31
scripts/verify-release.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]
then
printf 'Usage: %s VERSION CHANNEL OUTPUT_DIR\n' "$0" >&2
exit 2
fi
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "${SCRIPT_ROOT}/.." && pwd)"
PYTHON_BIN="${PTLINUX_PYTHON:-python3}"
ARGUMENTS=(
"${SCRIPT_ROOT}/verify_release.py"
"$1"
"$2"
"$3"
)
if [[ -n "${PTLINUX_UPDATE_PUBLIC_KEY:-}" ]]
then
ARGUMENTS+=(--public-key "$PTLINUX_UPDATE_PUBLIC_KEY")
fi
if [[ "${REQUIRE_RELEASE_SIGNATURE:-0}" == "1" ]]
then
ARGUMENTS+=(--require-signature)
fi
cd -- "$PROJECT_ROOT"
exec "$PYTHON_BIN" "${ARGUMENTS[@]}"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Verify a detached release-metadata signature with the launcher public key."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("signature", type=Path)
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:
verify_signature(arguments.source, arguments.signature, arguments.public_key)
except (OSError, ReleaseToolError) as exc:
print(f"Metadata verification failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

48
scripts/verify_release.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Verify one complete release artifact set before publication."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version")
parser.add_argument("channel")
parser.add_argument("output_directory", type=Path)
parser.add_argument("--public-key", type=Path)
parser.add_argument("--require-signature", action="store_true")
return parser
def main() -> int:
arguments = build_parser().parse_args()
try:
verify_release(
arguments.version,
arguments.channel,
arguments.output_directory,
public_key=arguments.public_key,
require_signature=arguments.require_signature,
)
except (OSError, ValueError, ReleaseToolError) as exc:
print(f"Release verification failed: {exc}", file=sys.stderr)
return 1
print(
f"Verified {arguments.channel} release {arguments.version} in "
f"{arguments.output_directory}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -13,9 +13,9 @@ __version__ = "0.1.0"
def __getattr__(name: str) -> Any:
"""Load diagnostic configuration helpers only when the full harness needs them.
The production launcher ships only :mod:`ptdiag.assbin`, which it uses for
verified scene handling. Deferring the larger diagnostic-only imports keeps
the compact package importable without shipping the investigation harness.
The launcher release retains :mod:`ptdiag.assbin` and :mod:`ptdiag.logs` as
compatibility façades. Deferring the larger diagnostic-only imports keeps the
compact package importable without shipping the investigation harness.
"""
if name in {"AppConfig", "ConfigError", "load_config"}:
from .config import AppConfig, ConfigError, load_config

22
src/ptdiag/artifacts.py Normal file
View File

@@ -0,0 +1,22 @@
"""Generated diagnostic output paths for checkout-only experiments."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
from .config import repository_root
RESULTS_ROOT_ENVIRONMENT_VARIABLE = "PTDIAG_RESULTS_ROOT"
def default_results_root(environment: Mapping[str, str] | None = None) -> Path:
"""Return the configurable root for generated diagnostic evidence."""
values = os.environ if environment is None else environment
explicit = values.get(RESULTS_ROOT_ENVIRONMENT_VARIABLE, "").strip()
if explicit:
return Path(explicit).expanduser().absolute()
return repository_root() / ".artifacts/diagnostics/results"

72
src/ptdiag/assbin.py Normal file
View File

@@ -0,0 +1,72 @@
"""Compatibility façade for launcher-owned wrapped Assbin handling."""
from ptlinux.game.compatibility.assbin import (
ASSBIN_CHUNK_AIANIMATION,
ASSBIN_CHUNK_AIMATERIAL,
ASSBIN_CHUNK_AIMATERIALPROPERTY,
ASSBIN_CHUNK_AIMESH,
ASSBIN_CHUNK_AINODE,
ASSBIN_CHUNK_AISCENE,
ASSBIN_CHUNK_AITEXTURE,
ASSBIN_HEADER_LENGTH,
MAX_CAPTURE_LENGTH,
MAX_STRING_LENGTH,
PROPERTY_TYPE_NAMES,
READ_CHUNK_SIZE,
AssbinError,
AssbinHeader,
BlobSummary,
BytesReader,
Chunk,
EmbeddedTexture,
Material,
MaterialProperty,
PngMetadata,
Reader,
SceneCounts,
SceneInspection,
XorFileReader,
_inspect_png,
_png_scanlines_sha256,
extract_embedded_texture,
inspect_wrapped_assbin,
normalize_png_16_rgb_to_8,
normalize_wrapped_assbin_textures,
xor_bytes,
)
__all__ = [
"ASSBIN_CHUNK_AIANIMATION",
"ASSBIN_CHUNK_AIMATERIAL",
"ASSBIN_CHUNK_AIMATERIALPROPERTY",
"ASSBIN_CHUNK_AIMESH",
"ASSBIN_CHUNK_AINODE",
"ASSBIN_CHUNK_AISCENE",
"ASSBIN_CHUNK_AITEXTURE",
"ASSBIN_HEADER_LENGTH",
"MAX_CAPTURE_LENGTH",
"MAX_STRING_LENGTH",
"PROPERTY_TYPE_NAMES",
"READ_CHUNK_SIZE",
"AssbinError",
"AssbinHeader",
"BlobSummary",
"BytesReader",
"Chunk",
"EmbeddedTexture",
"Material",
"MaterialProperty",
"PngMetadata",
"Reader",
"SceneCounts",
"SceneInspection",
"XorFileReader",
"_inspect_png",
"_png_scanlines_sha256",
"extract_embedded_texture",
"inspect_wrapped_assbin",
"normalize_png_16_rgb_to_8",
"normalize_wrapped_assbin_textures",
"xor_bytes",
]

View File

@@ -9,7 +9,8 @@ import re
import tomllib
DEFAULT_CONFIG_PATH = Path("config/local.toml")
DEFAULT_CONFIG_PATH = Path("diagnostics/config/local.toml")
LEGACY_CONFIG_PATH = Path("config/local.toml")
class ConfigError(ValueError):
@@ -40,19 +41,11 @@ class GameConfig:
virtual_desktop: str
@dataclass(frozen=True)
class SafetyConfig:
allow_live_game_modification: bool
allow_prefix_modification: bool
allow_binary_patch: bool
@dataclass(frozen=True)
class AppConfig:
paths: PathsConfig
proton7: Proton7Config
game: GameConfig
safety: SafetyConfig
source: Path
def to_dict(self) -> dict[str, Any]:
@@ -61,19 +54,48 @@ class AppConfig:
def project_root() -> Path:
"""Return the Python source root used by checkout-only helper commands."""
return Path(__file__).resolve().parent.parent
def repository_root() -> Path:
"""Return the repository root while retaining flat-install compatibility."""
source_root = project_root()
checkout_root = source_root.parent
if source_root.name == "src" and (checkout_root / "pyproject.toml").is_file():
return checkout_root
return source_root
def diagnostics_root() -> Path:
return repository_root() / "diagnostics"
def resolve_config_path(path: str | Path | None = None) -> Path:
if path is not None:
return Path(path).expanduser().absolute()
root = repository_root()
preferred = root / DEFAULT_CONFIG_PATH
legacy = root / LEGACY_CONFIG_PATH
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_config(path: str | Path | None = None) -> AppConfig:
config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH
config_path = config_path.expanduser().absolute()
config_path = resolve_config_path(path)
try:
with config_path.open("rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError as exc:
raise ConfigError(
f"Configuration not found: {config_path}. "
"Copy config/local.example.toml to config/local.toml."
"Copy diagnostics/config/local.example.toml to "
"diagnostics/config/local.toml. Legacy config/local.toml is also recognized."
) from exc
except tomllib.TOMLDecodeError as exc:
raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc
@@ -81,7 +103,6 @@ def load_config(path: str | Path | None = None) -> AppConfig:
paths = _section(data, "paths")
proton7 = _section(data, "proton7")
game = _section(data, "game")
safety = _section(data, "safety")
virtual_desktop = _required_string(game, "virtual_desktop")
if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None:
raise ConfigError("game.virtual_desktop must look like 1600x900")
@@ -105,15 +126,6 @@ def load_config(path: str | Path | None = None) -> AppConfig:
args=_required_string_tuple(game, "args"),
virtual_desktop=virtual_desktop,
),
safety=SafetyConfig(
allow_live_game_modification=_required_bool(
safety, "allow_live_game_modification"
),
allow_prefix_modification=_required_bool(
safety, "allow_prefix_modification"
),
allow_binary_patch=_required_bool(safety, "allow_binary_patch"),
),
source=config_path,
)
@@ -145,13 +157,6 @@ def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, .
return tuple(value)
def _required_bool(section: Mapping[str, Any], key: str) -> bool:
value = section.get(key)
if not isinstance(value, bool):
raise ConfigError(f"{key} must be true or false")
return value
def _paths_to_strings(value: Any) -> Any:
if isinstance(value, Path):
return str(value)

775
src/ptdiag/experiment.py Normal file
View File

@@ -0,0 +1,775 @@
"""Controlled Proton 7 runtime experiments and result generation."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any, Mapping, Sequence
from .artifacts import default_results_root
from .config import AppConfig, project_root
from .experiments.cleanup import (
DISPOSABLE_DIRECTORY_NAME as _DISPOSABLE_DIRECTORY_NAME,
cleanup_checked as _cleanup_checked_impl,
command_ok as _command_ok,
registry_restore_command as _registry_restore_command_impl,
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
remove_disposable_game as _remove_disposable_game_impl,
replace_game_link as _replace_game_link,
require_command as _require_command,
restore_game_link as _restore_game_link,
run_checked_commands as _run_checked_commands,
validate_disposable_path as _validate_disposable_path_impl,
)
from .experiments.evidence import (
collect_dll_overrides as _collect_dll_overrides_impl,
collect_hashes as _collect_hashes_impl,
desktop_name as _desktop_name,
finalize_result as _finalize_impl,
render_report as _report,
serialize_log_state as _serialize_log_state,
serialize_log_states as _serialize_log_states,
setup_failure_summary as _setup_failure_summary,
write_text as _write_text,
)
from .experiments.execution import (
capture_game_process as _capture_game_process_impl,
game_processes as _game_processes_impl,
run_and_watch as _run_and_watch_impl,
stop_process_group as _stop_process_group_impl,
)
from .experiments.log_selection import (
capture_current_native_logs,
native_log_priority as _native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .experiments.models import ExperimentError, ExperimentResult
from .experiments.preparation import (
_allocated_tree_bytes,
_apply_disposable_ini_overrides,
_dry_run_commands,
_ini_override_command,
_inspect_ini_overrides,
_launch_argv,
_log_modification_plan,
_preflight as _preflight_impl,
_prepare_disposable_game,
_prepare_state_cache,
_proton_command,
_proton_environment,
_resource_copy_paths,
_runtime_environment,
_safe_game_file,
_safe_resource_relative_path,
_state_cache_file_snapshot,
_state_cache_plan,
)
from .filesystem import sha256_file, verify_known_game_hashes
from .logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
from .ini import IniError
from .performance import PerformanceSampler
from .prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
)
from .process import collect_environment
from .registry import (
DesktopRegistrySnapshot,
desktop_registry_restored,
read_registry_string_values,
snapshot_desktop_registry,
)
from .result import ResultDirectory, create_result_directory, write_json
from .runner import CommandLogger
from .testcase import Testcase
from .trace import process_trace_shards
def run_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None = None,
) -> ExperimentResult:
if testcase.modification is not None:
return _run_modified_experiment(
config,
testcase,
dry_run=dry_run,
results_root=results_root,
)
return _run_standard_experiment(
config,
testcase,
dry_run=dry_run,
results_root=results_root,
)
def _run_standard_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None = None,
prepared_result: ResultDirectory | None = None,
started_at: datetime | None = None,
) -> ExperimentResult:
started_at = started_at or datetime.now().astimezone()
result = prepared_result or create_result_directory(
results_root or default_results_root(), testcase.name, started_at
)
logger = CommandLogger(result.commands_path)
(result.path / "runner.log").touch(exist_ok=True)
(result.path / "native.log").touch(exist_ok=True)
environment = collect_environment(config)
hashes = _collect_hashes(config, testcase)
write_json(result.path / "hashes.json", {"files": hashes})
desktop_name = _desktop_name(result.path)
metadata: dict[str, Any] = {
"test": testcase.name,
"description": testcase.description,
"started_at": started_at.isoformat(),
"result_directory": str(result.path),
"dry_run": dry_run,
"testcase": testcase.to_dict(),
"config_source": str(config.source),
"desktop_name": desktop_name,
"native_log_directory": str(config.paths.native_logs),
}
write_json(result.metadata_path, metadata)
preflight_errors = _preflight(config, testcase, environment, hashes)
baseline = snapshot_native_logs(config.paths.native_logs)
metadata["old_log_baseline"] = _serialize_log_states(baseline)
write_json(result.metadata_path, metadata)
registry_before: DesktopRegistrySnapshot | None = None
try:
registry_before = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
except OSError as exc:
preflight_errors.append(f"Could not snapshot dedicated-prefix registry: {exc}")
launch_argv = _launch_argv(config, testcase, result.path)
env_overrides = _runtime_environment(config, testcase, result.path)
state_cache_summary = _state_cache_plan(config, env_overrides)
dll_overrides = (
_collect_dll_overrides(config.proton7.prefix)
if testcase.capture.dll_overrides
else {}
)
_write_text(
result.path / "environment.txt",
json.dumps(
{
"preflight": environment,
"runtime_overrides": dict(sorted(env_overrides.items())),
"dll_overrides": dll_overrides,
},
indent=2,
sort_keys=True,
),
)
metadata["dll_overrides"] = dll_overrides
write_json(result.metadata_path, metadata)
summary: dict[str, Any] = {
"ok": False,
"status": "preflight-failed" if preflight_errors else "prepared",
"dry_run": dry_run,
"test": testcase.name,
"result_directory": str(result.path),
"preflight": {
"ok": not preflight_errors,
"errors": preflight_errors,
},
"launch": {
"argv": launch_argv,
"cwd": str(config.paths.live_game),
"env_overrides": dict(sorted(env_overrides.items())),
"timeout_seconds": testcase.timeout_seconds,
},
"native_log": {
"directory": str(config.paths.native_logs),
"baseline": _serialize_log_states(baseline),
"source": None,
"current_run_bytes": 0,
},
"registry": {
"temporary_change": testcase.display.virtual_desktop,
"before": registry_before.to_dict() if registry_before else None,
"after": None,
"restored": None,
},
"dxvk_state_cache": state_cache_summary,
}
if preflight_errors:
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
assert registry_before is not None
if dry_run:
_dry_run_commands(
config,
testcase,
logger,
registry_before,
launch_argv,
env_overrides,
)
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
summary["registry"]["after"] = registry_after.to_dict()
summary["registry"]["restored"] = desktop_registry_restored(
registry_before, registry_after
)
summary["status"] = "dry-run"
summary["ok"] = True
summary["review_gate"] = (
"Phase C dry-run complete; review artifacts before a live Game.exe launch."
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 0)
runtime_exit = 1
setup_complete = False
try:
if state_cache_summary is not None:
state_cache_summary = _prepare_state_cache(
config,
env_overrides,
logger,
)
summary["dxvk_state_cache"] = state_cache_summary
_cleanup_checked(config, logger)
if testcase.display.virtual_desktop:
_run_checked_commands(
logger,
_registry_setup_commands(config, desktop_name, testcase.display.size),
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
)
setup_complete = True
baseline = snapshot_native_logs(config.paths.native_logs)
metadata["old_log_baseline"] = _serialize_log_states(baseline)
write_json(result.metadata_path, metadata)
runtime = _run_and_watch(
config,
testcase,
result,
logger,
launch_argv,
env_overrides,
baseline,
)
summary["runtime"] = runtime
summary["native_log"].update(runtime.pop("native_log"))
runtime_exit = (
0
if runtime.get("failure_detected") or runtime.get("scene_import_survived")
else 1
)
trigger_epoch = runtime.get("trigger_epoch")
if testcase.capture.strace_files and isinstance(trigger_epoch, (int, float)):
trace = process_trace_shards(
result.path / "raw-strace",
result.path,
trigger_epoch=float(trigger_epoch),
focus_before_seconds=testcase.capture.focus_before_seconds,
focus_after_seconds=testcase.capture.focus_after_seconds,
live_game=config.paths.live_game,
)
summary["trace"] = trace.to_dict()
else:
summary["trace"] = None
if runtime.get("failure_detected"):
summary["status"] = "captured-scene-failure"
elif runtime.get("scene_import_survived"):
summary["status"] = "scene-import-survived"
else:
summary["status"] = "no-trigger"
summary["ok"] = bool(
runtime.get("failure_detected") or runtime.get("scene_import_survived")
)
except (
ExperimentError,
OSError,
ValueError,
PrefixSafetyError,
subprocess.SubprocessError,
) as exc:
summary["status"] = "runner-error"
summary["error"] = str(exc)
summary["ok"] = False
runtime_exit = 2
finally:
cleanup_error: str | None = None
try:
_cleanup_checked(config, logger)
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
cleanup_error = str(exc)
if testcase.display.virtual_desktop and setup_complete:
try:
_run_checked_commands(
logger,
_registry_restore_commands(config, registry_before),
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
)
except ExperimentError as exc:
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
try:
registry_after = snapshot_desktop_registry(config.proton7.prefix, desktop_name)
summary["registry"]["after"] = registry_after.to_dict()
summary["registry"]["restored"] = desktop_registry_restored(
registry_before, registry_after
)
except OSError as exc:
cleanup_error = f"{cleanup_error}; {exc}" if cleanup_error else str(exc)
if cleanup_error:
summary["cleanup_error"] = cleanup_error
summary["ok"] = False
runtime_exit = 2
if state_cache_summary is not None:
state_cache_summary["after"] = _state_cache_file_snapshot(
Path(str(state_cache_summary["cache_file"]))
)
summary["dxvk_state_cache"] = state_cache_summary
_finalize(result, summary)
return ExperimentResult(result.path, summary, runtime_exit)
def _run_modified_experiment(
config: AppConfig,
testcase: Testcase,
*,
dry_run: bool,
results_root: Path | None,
) -> ExperimentResult:
modification = testcase.modification
assert modification is not None
started_at = datetime.now().astimezone()
result = create_result_directory(
results_root or default_results_root(), testcase.name, started_at
)
(result.path / "runner.log").touch(exist_ok=True)
(result.path / "native.log").touch(exist_ok=True)
logger = CommandLogger(result.commands_path)
source_environment = collect_environment(config)
source_hashes = _collect_hashes(config, testcase)
source_errors = _preflight(config, testcase, source_environment, source_hashes)
write_json(
result.path / "source-preflight.json",
{
"environment": source_environment,
"hashes": source_hashes,
"ok": not source_errors,
"errors": source_errors,
},
)
disposable_game = (
config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME / result.path.name
).absolute()
modification_summary: dict[str, Any] = {
"kind": modification.kind,
"source": modification.source,
"destination": modification.destination,
"texture_indices": list(modification.texture_indices),
"ini_overrides": [
{
"path": item.path,
"section": item.section,
"key": item.key,
"expected": item.expected,
"value": item.value,
}
for item in modification.ini_overrides
],
"disposable_game": str(disposable_game),
"source_preflight_ok": not source_errors,
"prepared": False,
"prefix_link_restored": None,
"disposable_removed": None,
}
write_json(result.path / "modification.json", modification_summary)
if source_errors:
summary = _setup_failure_summary(
testcase, result, dry_run, source_errors, modification_summary
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
try:
source, live_destination = _resource_copy_paths(
config.paths.live_game, modification
)
ini_preflight = _inspect_ini_overrides(
config.paths.live_game,
modification.ini_overrides,
)
for item in ini_preflight:
if not item["matches_expected"]:
raise ExperimentError(
f"INI preflight mismatch for [{item['section']}] {item['key']}: "
f"expected {item['expected']!r}, found {item['current']!r}"
)
except (OSError, ValueError, ExperimentError) as exc:
summary = _setup_failure_summary(
testcase, result, dry_run, [str(exc)], modification_summary
)
_finalize(result, summary)
return ExperimentResult(result.path, summary, 2)
modification_summary.update(
{
"source_path": str(source),
"live_destination_path": str(live_destination),
"live_destination_existed_before": live_destination.exists(),
"source_sha256": sha256_file(source),
"source_size": source.stat().st_size,
"ini_preflight": ini_preflight,
}
)
if dry_run:
_log_modification_plan(
logger,
config,
disposable_game,
source,
modification,
)
outcome = _run_standard_experiment(
config,
testcase,
dry_run=True,
prepared_result=result,
started_at=started_at,
)
modification_summary["status"] = "planned-only"
outcome.summary["modification"] = modification_summary
write_json(result.path / "modification.json", modification_summary)
_finalize(result, outcome.summary)
return outcome
prefix_link = (
config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU"
)
original_link_target: str | None = None
link_changed = False
disposable_created = False
runtime_config = config
outcome: ExperimentResult | None = None
setup_error: str | None = None
cleanup_errors: list[str] = []
try:
_cleanup_checked(config, logger)
preparation = _prepare_disposable_game(
config,
result,
logger,
modification,
disposable_game,
source,
)
disposable_created = True
modification_summary.update(preparation)
original_link_target = _replace_game_link(
prefix_link,
disposable_game,
config.paths.live_game,
logger,
)
link_changed = True
modification_summary.update(
{
"prefix_link": str(prefix_link),
"prefix_link_original_target": original_link_target,
"prefix_link_runtime_target": str(disposable_game),
}
)
runtime_config = replace(
config,
paths=replace(
config.paths,
live_game=disposable_game,
native_logs=disposable_game / "save/logs",
),
)
modification_summary["prepared"] = True
write_json(result.path / "modification.json", modification_summary)
outcome = _run_standard_experiment(
runtime_config,
testcase,
dry_run=False,
prepared_result=result,
started_at=started_at,
)
except (
ExperimentError,
OSError,
ValueError,
PrefixSafetyError,
subprocess.SubprocessError,
) as exc:
setup_error = str(exc)
finally:
try:
_cleanup_checked(runtime_config, logger)
except (ExperimentError, OSError, ValueError, PrefixSafetyError) as exc:
cleanup_errors.append(f"wineserver cleanup: {exc}")
if disposable_created and disposable_game.exists() and modification.ini_overrides:
try:
modification_summary["ini_runtime_final"] = _inspect_ini_overrides(
disposable_game,
modification.ini_overrides,
)
except (ExperimentError, IniError, OSError) as exc:
cleanup_errors.append(f"final INI capture: {exc}")
if link_changed and original_link_target is not None:
try:
_restore_game_link(
prefix_link,
original_link_target,
config.paths.live_game,
logger,
)
modification_summary["prefix_link_restored"] = True
except (ExperimentError, OSError) as exc:
modification_summary["prefix_link_restored"] = False
cleanup_errors.append(f"prefix link restore: {exc}")
if disposable_created or disposable_game.exists():
try:
_remove_disposable_game(config, result, disposable_game, logger)
modification_summary["disposable_removed"] = True
except (ExperimentError, OSError) as exc:
modification_summary["disposable_removed"] = False
cleanup_errors.append(f"disposable cleanup: {exc}")
if outcome is None:
errors = [setup_error or "Modified experiment did not start", *cleanup_errors]
summary = _setup_failure_summary(
testcase, result, False, errors, modification_summary
)
exit_code = 2
else:
summary = outcome.summary
exit_code = outcome.exit_code
if setup_error:
summary["status"] = "setup-error"
summary["error"] = setup_error
summary["ok"] = False
exit_code = 2
if cleanup_errors:
summary["cleanup_error"] = "; ".join(cleanup_errors)
summary["ok"] = False
exit_code = 2
summary["modification"] = modification_summary
write_json(result.path / "modification.json", modification_summary)
_finalize(result, summary)
return ExperimentResult(result.path, summary, exit_code)
def _remove_disposable_game(
config: AppConfig,
result: ResultDirectory,
disposable_game: Path,
logger: CommandLogger,
) -> None:
_remove_disposable_game_impl(
config,
result,
disposable_game,
logger,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _validate_disposable_path(
staging_root: Path,
path: Path,
expected_name: str,
) -> None:
_validate_disposable_path_impl(
staging_root,
path,
expected_name,
directory_name=_DISPOSABLE_DIRECTORY_NAME,
)
def _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
) -> list[str]:
return _preflight_impl(
config,
testcase,
environment,
hashes,
game_processes=_game_processes,
)
def _run_and_watch(
config: AppConfig,
testcase: Testcase,
result: ResultDirectory,
logger: CommandLogger,
argv: Sequence[str],
env_overrides: Mapping[str, str],
baseline: Mapping[str, LogFileState],
) -> dict[str, Any]:
return _run_and_watch_impl(
config,
testcase,
result,
logger,
argv,
env_overrides,
baseline,
popen=subprocess.Popen,
clock_ns=time.time_ns,
sleep=time.sleep,
now=lambda: datetime.now().astimezone(),
environment_factory=lambda: os.environ.copy(),
json_writer=write_json,
process_capture=_capture_game_process,
process_stopper=_stop_process_group,
performance_sampler_factory=PerformanceSampler,
log_observer=observe_current_native_logs,
log_capture=capture_current_native_logs,
scene_survived=scene_import_survived,
serialize_state=_serialize_log_state,
text_writer=_write_text,
prioritize_log=_native_log_priority,
discover_logs=discover_current_native_logs,
parse_log=parse_current_run_log,
read_log=read_current_run_log_text,
snapshot_logs=snapshot_native_logs,
)
def _stop_process_group(process: subprocess.Popen[str]) -> None:
_stop_process_group_impl(
process,
kill_group=os.killpg,
signals=(
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
)
def _capture_game_process(
testcase: Testcase,
result_directory: Path,
) -> dict[str, Any] | None:
return _capture_game_process_impl(
testcase,
result_directory,
game_process_scan=_game_processes,
proc_root=Path("/proc"),
copy_file=shutil.copyfile,
)
def _game_processes() -> list[int]:
return _game_processes_impl(proc_root=Path("/proc"))
def _registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
) -> list[list[str]]:
return _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)
def _registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
) -> list[str]:
return _registry_restore_command_impl(
config,
key,
name,
existed,
value,
proton_command=_proton_command,
)
def _cleanup_checked(config: AppConfig, logger: CommandLogger) -> None:
_cleanup_checked_impl(
config,
logger,
cleaner=cleanup_dedicated_wineserver,
)
def _collect_hashes(
config: AppConfig,
testcase: Testcase,
) -> list[dict[str, Any]]:
return _collect_hashes_impl(
config,
testcase,
repository_root=project_root(),
verifier=verify_known_game_hashes,
hash_file=sha256_file,
)
def _collect_dll_overrides(prefix: Path) -> dict[str, str]:
return _collect_dll_overrides_impl(
prefix,
reader=read_registry_string_values,
)
def _finalize(result: ResultDirectory, summary: dict[str, Any]) -> None:
_finalize_impl(
result,
summary,
json_writer=write_json,
text_writer=_write_text,
report_renderer=_report,
)

View File

@@ -0,0 +1 @@
"""Controlled diagnostic experiment implementation modules."""

View File

@@ -0,0 +1,287 @@
"""Experiment cleanup, prefix-link rollback, and registry restoration."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from ..config import AppConfig
from ..prefix import cleanup_dedicated_wineserver
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger, CommandResult
from .models import ExperimentError
EXPLORER_KEY = r"HKCU\Software\Wine\Explorer"
DESKTOPS_KEY = r"HKCU\Software\Wine\Explorer\Desktops"
DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable"
ProtonCommand = Callable[..., list[str]]
WineserverCleaner = Callable[..., CommandResult]
def replace_game_link(
prefix_link: Path,
disposable_game: Path,
expected_live_game: Path,
logger: CommandLogger,
) -> str:
"""Point the dedicated prefix at a verified disposable game tree."""
if not prefix_link.is_symlink():
raise ExperimentError(
f"Dedicated-prefix game path is not a symlink: {prefix_link}"
)
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
raise ExperimentError("Dedicated-prefix game link changed after source preflight")
original_target = os.readlink(prefix_link)
require_command(
logger.run(
["ln", "-sfn", "--", str(disposable_game), str(prefix_link)],
cwd=prefix_link.parent,
timeout_seconds=15.0,
),
"point dedicated prefix at disposable game",
)
if (
not prefix_link.is_symlink()
or prefix_link.resolve(strict=True) != disposable_game.resolve(strict=True)
):
raise ExperimentError(
"Dedicated-prefix game link did not resolve to disposable copy"
)
return original_target
def restore_game_link(
prefix_link: Path,
original_target: str,
expected_live_game: Path,
logger: CommandLogger,
) -> None:
"""Restore the dedicated-prefix game link and verify its exact target."""
require_command(
logger.run(
["ln", "-sfn", "--", original_target, str(prefix_link)],
cwd=prefix_link.parent,
timeout_seconds=15.0,
),
"restore dedicated-prefix game link",
)
if not prefix_link.is_symlink():
raise ExperimentError("Restored dedicated-prefix game path is not a symlink")
if os.readlink(prefix_link) != original_target:
raise ExperimentError("Dedicated-prefix game link text was not restored exactly")
if prefix_link.resolve(strict=True) != expected_live_game.resolve(strict=True):
raise ExperimentError("Dedicated-prefix game link target was not restored")
def remove_disposable_game(
config: AppConfig,
result: ResultDirectory,
disposable_game: Path,
logger: CommandLogger,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Remove only the result-scoped disposable tree beneath its managed root."""
staging_root = (config.paths.app_root / directory_name).absolute()
validate_disposable_path(
staging_root,
disposable_game,
result.path.name,
directory_name=directory_name,
)
require_command(
logger.run(
["rm", "-rf", "--one-file-system", "--", str(disposable_game)],
timeout_seconds=900.0,
),
"remove disposable game tree",
)
if disposable_game.exists() or disposable_game.is_symlink():
raise ExperimentError(
f"Disposable game cleanup left its target: {disposable_game}"
)
try:
staging_root.rmdir()
except OSError:
pass
def validate_disposable_path(
staging_root: Path,
path: Path,
expected_name: str,
*,
directory_name: str = DISPOSABLE_DIRECTORY_NAME,
) -> None:
"""Reject cleanup targets outside the managed, result-scoped staging root."""
if path.parent != staging_root or path.name != expected_name:
raise ExperimentError(f"Unsafe disposable game path: {path}")
if staging_root.name != directory_name:
raise ExperimentError(f"Unsafe disposable staging root: {staging_root}")
def require_command(outcome: CommandResult, operation: str) -> None:
if not command_ok(outcome):
raise ExperimentError(
f"Could not {operation} ({outcome.returncode}): "
f"{outcome.error or outcome.stderr.strip() or 'command failed'}"
)
def registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
*,
proton_command: ProtonCommand,
) -> list[list[str]]:
return [
proton_command(
config,
"runinprefix",
"reg.exe",
"add",
EXPLORER_KEY,
"/v",
"Desktop",
"/t",
"REG_SZ",
"/d",
desktop_name,
"/f",
),
proton_command(
config,
"runinprefix",
"reg.exe",
"add",
DESKTOPS_KEY,
"/v",
desktop_name,
"/t",
"REG_SZ",
"/d",
desktop_size,
"/f",
),
]
def registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
*,
proton_command: ProtonCommand,
) -> list[list[str]]:
return [
registry_restore_command(
config,
EXPLORER_KEY,
"Desktop",
snapshot.selected_desktop.exists,
snapshot.selected_desktop.value,
proton_command=proton_command,
),
registry_restore_command(
config,
DESKTOPS_KEY,
snapshot.desktop_name,
snapshot.desktop_size.exists,
snapshot.desktop_size.value,
proton_command=proton_command,
),
]
def registry_restore_command(
config: AppConfig,
key: str,
name: str,
existed: bool,
value: str | None,
*,
proton_command: ProtonCommand,
) -> list[str]:
if existed:
assert value is not None
return proton_command(
config,
"runinprefix",
"reg.exe",
"add",
key,
"/v",
name,
"/t",
"REG_SZ",
"/d",
value,
"/f",
)
return proton_command(
config,
"runinprefix",
"reg.exe",
"delete",
key,
"/v",
name,
"/f",
)
def run_checked_commands(
logger: CommandLogger,
commands: Sequence[Sequence[str]],
*,
cwd: Path,
env_overrides: Mapping[str, str],
) -> None:
for command in commands:
outcome = logger.run(
command,
cwd=cwd,
env_overrides=env_overrides,
timeout_seconds=15.0,
)
if not command_ok(outcome):
raise ExperimentError(
f"Command failed ({outcome.returncode}): {list(command)!r}: "
f"{outcome.error or outcome.stderr.strip()}"
)
def cleanup_checked(
config: AppConfig,
logger: CommandLogger,
*,
cleaner: WineserverCleaner = cleanup_dedicated_wineserver,
) -> None:
"""Stop the dedicated wineserver while accepting its idle return contract."""
outcome = cleaner(config, logger, timeout_seconds=15.0)
if (
outcome.returncode == 1
and not outcome.timed_out
and outcome.error is None
and not outcome.stdout
and not outcome.stderr
):
return
if not command_ok(outcome):
raise ExperimentError(
"Dedicated-prefix wineserver cleanup failed: "
f"{outcome.error or outcome.stderr.strip() or outcome.returncode}"
)
def command_ok(result: CommandResult) -> bool:
return not result.timed_out and result.error is None and result.returncode == 0

View File

@@ -0,0 +1,257 @@
"""Evidence collection, serialization, and experiment report generation."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import os
from pathlib import Path
from typing import Any, Protocol
from ..config import AppConfig, project_root
from ..filesystem import sha256_file, verify_known_game_hashes
from ..logs import LogFileState
from ..registry import read_registry_string_values
from ..result import ResultDirectory, write_json
from ..testcase import Testcase
class HashVerification(Protocol):
def to_dict(self) -> dict[str, Any]: ...
HashVerifier = Callable[[Path], Sequence[HashVerification]]
HashFile = Callable[[Path], str]
RegistryReader = Callable[[Path], Mapping[str, Mapping[str, str]]]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
ReportRenderer = Callable[[Mapping[str, Any]], str]
def collect_hashes(
config: AppConfig,
testcase: Testcase,
*,
repository_root: Path | None = None,
verifier: HashVerifier = verify_known_game_hashes,
hash_file: HashFile = sha256_file,
) -> list[dict[str, Any]]:
"""Collect the immutable inputs needed to interpret one experiment."""
root = project_root() if repository_root is None else repository_root
items: list[dict[str, Any]] = []
for verification in verifier(config.paths.live_game):
item = verification.to_dict()
item["required"] = True
item["role"] = "known-game-baseline"
items.append(item)
extra = {
config.proton7.proton: "proton-launcher",
config.proton7.prefix
/ "drive_c/windows/syswow64/d3dx9_43.dll": "native-d3dx9-43",
config.proton7.prefix
/ "drive_c/windows/syswow64/d3dcompiler_43.dll": "native-d3dcompiler-43",
config.proton7.prefix / "drive_c/windows/syswow64/d3d9.dll": "dxvk-d3d9",
root / "ptlinux/proton_compat.py": "proton-python314-shim",
config.source: "local-config",
testcase.source: "testcase",
}
dxvk_config = testcase.env.get("DXVK_CONFIG_FILE")
if dxvk_config:
dxvk_config_path = Path(dxvk_config).expanduser()
if not dxvk_config_path.is_absolute():
dxvk_config_path = (root / dxvk_config_path).absolute()
extra[dxvk_config_path] = "dxvk-config"
for path, role in extra.items():
item: dict[str, Any] = {
"path": str(path),
"role": role,
"required": role not in {"local-config", "testcase"},
"expected_sha256": None,
"actual_sha256": None,
"ok": False,
"error": None,
}
try:
item["actual_sha256"] = hash_file(path)
item["ok"] = True
except OSError as error:
item["error"] = str(error)
items.append(item)
return items
def collect_dll_overrides(
prefix: Path,
*,
reader: RegistryReader = read_registry_string_values,
) -> dict[str, str]:
sections = reader(prefix / "user.reg")
return dict(sorted(sections.get(r"Software\Wine\DllOverrides", {}).items()))
def serialize_log_states(states: Mapping[str, LogFileState]) -> list[dict[str, Any]]:
return [
serialize_log_state(state)
for state in sorted(states.values(), key=lambda item: str(item.path))
]
def serialize_log_state(state: LogFileState) -> dict[str, Any]:
return {
"path": str(state.path),
"mtime_ns": state.mtime_ns,
"size": state.size,
"inode": state.inode,
}
def desktop_name(result_directory: Path) -> str:
digits = "".join(character for character in result_directory.name if character.isdigit())
return f"PTDiag{digits[-12:]}"
def setup_failure_summary(
testcase: Testcase,
result: ResultDirectory,
dry_run: bool,
errors: Sequence[str],
modification: Mapping[str, Any],
) -> dict[str, Any]:
return {
"ok": False,
"status": "preflight-failed" if errors else "setup-error",
"dry_run": dry_run,
"test": testcase.name,
"result_directory": str(result.path),
"preflight": {"ok": False, "errors": list(errors)},
"modification": dict(modification),
"native_log": {"source": None, "current_run_bytes": 0},
"registry": {"restored": None},
}
def finalize_result(
result: ResultDirectory,
summary: dict[str, Any],
*,
json_writer: JsonWriter = write_json,
text_writer: TextWriter,
report_renderer: ReportRenderer,
) -> None:
json_writer(result.path / "summary.json", summary)
text_writer(result.path / "report.md", report_renderer(summary))
def render_report(summary: Mapping[str, Any]) -> str:
preflight = summary.get("preflight", {})
launch = summary.get("launch", {})
registry = summary.get("registry", {})
native_log = summary.get("native_log", {})
modification = summary.get("modification")
state_cache = summary.get("dxvk_state_cache")
runtime = summary.get("runtime")
lines = [
f"# {summary.get('test', 'experiment')}",
"",
f"Status: `{summary.get('status')}`",
f"Result directory: `{summary.get('result_directory')}`",
f"Dry run: `{summary.get('dry_run')}`",
"",
"## Preflight",
"",
(
f"Passed: `{preflight.get('ok')}`"
if isinstance(preflight, Mapping)
else "Passed: `False`"
),
]
if isinstance(preflight, Mapping):
for error in preflight.get("errors", []):
lines.append(f"- {error}")
lines.extend(
[
"",
"## Launch",
"",
f"CWD: `{launch.get('cwd')}`" if isinstance(launch, Mapping) else "",
(
f"Timeout: `{launch.get('timeout_seconds')}` seconds"
if isinstance(launch, Mapping)
else ""
),
"",
"## Current-run native log",
"",
(
f"Source: `{native_log.get('source')}`"
if isinstance(native_log, Mapping)
else ""
),
(
f"Captured bytes: `{native_log.get('current_run_bytes')}`"
if isinstance(native_log, Mapping)
else ""
),
"",
"## Registry rollback",
"",
(
f"Restored: `{registry.get('restored')}`"
if isinstance(registry, Mapping)
else ""
),
]
)
if summary.get("review_gate"):
lines.extend(["", "## Review gate", "", str(summary["review_gate"])])
if isinstance(runtime, Mapping) and isinstance(runtime.get("performance"), Mapping):
performance = runtime["performance"]
lines.extend(
[
"",
"## Performance capture",
"",
f"Samples: `{performance.get('sample_count')}`",
f"Interval: `{performance.get('interval_seconds')}` seconds",
f"CSV: `{performance.get('output')}`",
]
)
if isinstance(state_cache, Mapping):
lines.extend(
[
"",
"## DXVK state cache",
"",
f"Directory: `{state_cache.get('directory')}`",
f"Seeded: `{state_cache.get('seeded')}`",
f"Before: `{state_cache.get('before')}`",
f"After: `{state_cache.get('after')}`",
]
)
if isinstance(modification, Mapping):
lines.extend(
[
"",
"## Disposable modification",
"",
f"Kind: `{modification.get('kind')}`",
f"Source: `{modification.get('source')}`",
f"Destination: `{modification.get('destination')}`",
f"Prepared: `{modification.get('prepared')}`",
f"Prefix link restored: `{modification.get('prefix_link_restored')}`",
f"Disposable copy removed: `{modification.get('disposable_removed')}`",
]
)
if summary.get("error"):
lines.extend(["", "## Error", "", str(summary["error"])])
return "\n".join(line for line in lines if line is not None).rstrip() + "\n"
def write_text(path: Path, value: str, *, pid: int | None = None) -> None:
"""Atomically write normalized UTF-8 text evidence."""
path.parent.mkdir(parents=True, exist_ok=True)
process_id = os.getpid() if pid is None else pid
temporary = path.with_name(f".{path.name}.{process_id}.tmp")
temporary.write_text(value.rstrip("\n") + "\n", encoding="utf-8")
temporary.replace(path)

View File

@@ -0,0 +1,332 @@
"""Process execution and runtime observation for diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
import os
from pathlib import Path
import shutil
import signal
import subprocess
import time
from typing import Any
from ..config import AppConfig
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
from ..performance import PerformanceSampler
from ..result import ResultDirectory, write_json
from ..runner import CommandLogger
from ..testcase import Testcase
from .evidence import serialize_log_state, write_text
from .log_selection import (
NativeLogObservation,
NativeLogSelection,
capture_current_native_logs,
native_log_priority,
observe_current_native_logs,
scene_import_survived,
)
from .models import ExperimentError
ProcessFactory = Callable[..., subprocess.Popen[str]]
ProcessCapture = Callable[[Testcase, Path], dict[str, Any] | None]
ProcessStopper = Callable[[subprocess.Popen[str]], None]
PerformanceSamplerFactory = Callable[[int, Path], PerformanceSampler]
JsonWriter = Callable[[Path, Any], None]
TextWriter = Callable[[Path, str], None]
EnvironmentFactory = Callable[[], dict[str, str]]
LogObserver = Callable[..., Sequence[NativeLogObservation]]
LogCapture = Callable[..., NativeLogSelection]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
LogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
def game_processes(*, proc_root: Path = Path("/proc")) -> list[int]:
"""Return the matching game process IDs visible under one proc tree."""
result: list[int] = []
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
comm = (entry / "comm").read_text(encoding="utf-8").strip().casefold()
except OSError:
continue
if comm in {"game.exe", "gamese.exe"}:
result.append(int(entry.name))
return sorted(result)
def capture_game_process(
testcase: Testcase,
result_directory: Path,
*,
game_process_scan: Callable[[], list[int]] = game_processes,
proc_root: Path = Path("/proc"),
copy_file: Callable[[Path, Path], str] = shutil.copyfile,
) -> dict[str, Any] | None:
"""Capture the first matching process identity and optional memory map."""
processes = game_process_scan()
if not processes:
return None
pid = processes[0]
root = proc_root / str(pid)
identity: dict[str, Any] = {"pid": pid}
try:
identity["comm"] = (root / "comm").read_text(encoding="utf-8").strip()
identity["cmdline"] = (
(root / "cmdline").read_bytes().decode("utf-8", errors="replace").split("\0")
)
identity["cwd"] = str((root / "cwd").readlink())
identity["exe"] = str((root / "exe").readlink())
if testcase.capture.process_maps:
copy_file(root / "maps", result_directory / "process-maps.txt")
except OSError as exc:
identity["capture_error"] = str(exc)
return identity
def stop_process_group(
process: subprocess.Popen[str],
*,
kill_group: Callable[[int, int], None] = os.killpg,
signals: Sequence[tuple[int, float]] = (
(signal.SIGINT, 5.0),
(signal.SIGTERM, 5.0),
(signal.SIGKILL, 3.0),
),
) -> None:
"""Stop a launcher process group with bounded escalation."""
if process.poll() is not None:
return
for signum, timeout in signals:
try:
kill_group(process.pid, signum)
except ProcessLookupError:
return
try:
process.wait(timeout=timeout)
return
except subprocess.TimeoutExpired:
continue
def _current_time() -> datetime:
return datetime.now().astimezone()
def _copy_environment() -> dict[str, str]:
return os.environ.copy()
def run_and_watch(
config: AppConfig,
testcase: Testcase,
result: ResultDirectory,
logger: CommandLogger,
argv: Sequence[str],
env_overrides: Mapping[str, str],
baseline: Mapping[str, LogFileState],
*,
popen: ProcessFactory = subprocess.Popen,
clock_ns: Callable[[], int] = time.time_ns,
sleep: Callable[[float], None] = time.sleep,
now: Callable[[], datetime] = _current_time,
environment_factory: EnvironmentFactory = _copy_environment,
json_writer: JsonWriter = write_json,
process_capture: ProcessCapture = capture_game_process,
process_stopper: ProcessStopper = stop_process_group,
performance_sampler_factory: PerformanceSamplerFactory = PerformanceSampler,
log_observer: LogObserver = observe_current_native_logs,
log_capture: LogCapture = capture_current_native_logs,
scene_survived: Callable[..., bool] = scene_import_survived,
serialize_state: LogStateSerializer = serialize_log_state,
text_writer: TextWriter = write_text,
prioritize_log: LogPrioritizer = native_log_priority,
discover_logs: Callable[..., Sequence[LogFileState]] = discover_current_native_logs,
parse_log: Callable[..., Any] = parse_current_run_log,
read_log: Callable[..., str] = read_current_run_log_text,
snapshot_logs: Callable[[Path], Mapping[str, LogFileState]] = snapshot_native_logs,
) -> dict[str, Any]:
"""Run one diagnostic process and collect its current-run evidence."""
runner_log_path = result.path / "runner.log"
environment = environment_factory()
environment.update(env_overrides)
test_start_ns = clock_ns()
started_at = now()
json_writer(
result.path / "test-start.json",
{
"test_start_ns": test_start_ns,
"test_start_epoch": test_start_ns / 1_000_000_000,
"recorded_at": started_at.isoformat(),
},
)
process: subprocess.Popen[str] | None = None
process_identity: dict[str, Any] | None = None
failure_state: LogFileState | None = None
failure_trigger_mtime_ns: int | None = None
current_observation: NativeLogObservation | None = None
performance_sampler: PerformanceSampler | None = None
performance_summary: dict[str, Any] | None = None
detected_at_ns: int | None = None
timed_out = False
stop_reason = "process-exited"
failure_reason: str | None = None
with runner_log_path.open("a", encoding="utf-8") as runner_log:
try:
process = popen(
list(argv),
cwd=config.paths.live_game,
env=environment,
stdout=runner_log,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
except OSError as exc:
logger.record_lifecycle(
argv,
state="launch-error",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
error=str(exc),
)
raise ExperimentError(f"Could not launch experiment: {exc}") from exc
assert process is not None
logger.record_lifecycle(
argv,
state="started",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
pid=process.pid,
)
deadline_ns = test_start_ns + int(testcase.timeout_seconds * 1_000_000_000)
post_failure_deadline_ns: int | None = None
while process.poll() is None:
now_ns = clock_ns()
if process_identity is None:
process_identity = process_capture(testcase, result.path)
if process_identity is not None and testcase.capture.performance_stats:
try:
performance_sampler = performance_sampler_factory(
int(process_identity["pid"]),
result.path / "performance.csv",
)
except (OSError, ValueError, KeyError) as exc:
performance_summary = {"error": str(exc)}
if performance_sampler is not None:
performance_sampler.maybe_sample(now_ns)
if failure_state is None:
for observation in log_observer(
config.paths.native_logs,
baseline,
test_start_ns,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
):
current_observation = observation
if observation.failure_reason is not None:
failure_state = observation.state
failure_trigger_mtime_ns = observation.state.mtime_ns
detected_at_ns = now_ns
post_failure_deadline_ns = now_ns + int(
testcase.stop_after_failure_seconds * 1_000_000_000
)
failure_reason = observation.failure_reason
break
if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns:
stop_reason = failure_reason or "load-texture-to-material"
process_stopper(process)
break
if now_ns >= deadline_ns:
timed_out = True
stop_reason = "timeout"
process_stopper(process)
break
sleep(0.1)
if process.poll() is None:
process_stopper(process)
returncode = process.wait(timeout=5.0)
if performance_sampler is not None:
performance_summary = performance_sampler.close()
logger.record_lifecycle(
argv,
state="finished",
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
started_at=started_at,
pid=process.pid,
returncode=returncode,
timed_out=timed_out,
)
selection = log_capture(
config.paths.native_logs,
baseline,
test_start_ns,
result.path,
fallback=current_observation,
failure_state=failure_state,
serialize_state=serialize_state,
write_text=text_writer,
prioritize=prioritize_log,
discoverer=discover_logs,
parser=parse_log,
reader=read_log,
snapshotter=snapshot_logs,
)
current_observation = selection.observation
current_state = current_observation.state if current_observation is not None else None
current_text = current_observation.text if current_observation is not None else ""
current_analysis = (
current_observation.analysis if current_observation is not None else None
)
survived = scene_survived(current_analysis, timed_out=timed_out)
trigger_ns = (
max(test_start_ns, failure_trigger_mtime_ns)
if failure_trigger_mtime_ns is not None
else None
)
return {
"test_start_ns": test_start_ns,
"test_start_epoch": test_start_ns / 1_000_000_000,
"finished_at": now().isoformat(),
"launcher_pid": process.pid if process is not None else None,
"game_process": process_identity,
"returncode": process.returncode if process is not None else None,
"timed_out": timed_out,
"stop_reason": stop_reason,
"failure_detected": failure_state is not None,
"scene_import_survived": survived,
"detected_at_ns": detected_at_ns,
"trigger_epoch": trigger_ns / 1_000_000_000 if trigger_ns is not None else None,
"analysis": current_analysis,
"native_logs": list(selection.captures),
"performance": performance_summary,
"native_log": {
"source": str(current_state.path) if current_state else None,
"state": serialize_state(current_state) if current_state else None,
"current_run_bytes": len(current_text.encode("utf-8")),
},
}

View File

@@ -0,0 +1,212 @@
"""Current-run native-log observation, capture, and selection."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from ..logs import (
LogFileState,
discover_current_native_logs,
parse_current_run_log,
read_current_run_log_text,
snapshot_native_logs,
)
class NativeLogAnalysis(Protocol):
load_texture_to_material_failed: bool
asset_pack_health_failed: bool
def to_summary(self) -> dict[str, Any]: ...
NativeLogDiscoverer = Callable[
[Path, Mapping[str, LogFileState], int],
Sequence[LogFileState],
]
NativeLogParser = Callable[
[LogFileState, Mapping[str, LogFileState]],
NativeLogAnalysis,
]
NativeLogReader = Callable[
[LogFileState, Mapping[str, LogFileState]],
str,
]
NativeLogSnapshotter = Callable[[Path], Mapping[str, LogFileState]]
LogStateSerializer = Callable[[LogFileState], dict[str, Any]]
NativeLogPrioritizer = Callable[
[LogFileState, Mapping[str, Any]],
tuple[int, int, int, int, str],
]
TextWriter = Callable[[Path, str], None]
@dataclass(frozen=True)
class NativeLogObservation:
state: LogFileState
text: str
analysis: dict[str, Any]
failure_reason: str | None
@dataclass(frozen=True)
class NativeLogSelection:
observation: NativeLogObservation | None
captures: tuple[dict[str, Any], ...]
def observe_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
*,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
) -> tuple[NativeLogObservation, ...]:
"""Parse each current-run candidate and identify an actionable failure."""
observations: list[NativeLogObservation] = []
for state in discoverer(log_directory, baseline, test_start_ns):
parsed = parser(state, baseline)
failure_reason = (
"asset-pack-health"
if parsed.asset_pack_health_failed
else "load-texture-to-material"
if parsed.load_texture_to_material_failed
else None
)
observations.append(
NativeLogObservation(
state=state,
text=reader(state, baseline),
analysis=parsed.to_summary(),
failure_reason=failure_reason,
)
)
return tuple(observations)
def capture_current_native_logs(
log_directory: Path,
baseline: Mapping[str, LogFileState],
test_start_ns: int,
result_directory: Path,
*,
fallback: NativeLogObservation | None,
failure_state: LogFileState | None,
serialize_state: LogStateSerializer,
write_text: TextWriter,
prioritize: NativeLogPrioritizer | None = None,
discoverer: NativeLogDiscoverer = discover_current_native_logs,
parser: NativeLogParser = parse_current_run_log,
reader: NativeLogReader = read_current_run_log_text,
snapshotter: NativeLogSnapshotter = snapshot_native_logs,
) -> NativeLogSelection:
"""Capture all current-run logs and choose the strongest primary evidence."""
captures: list[dict[str, Any]] = []
candidates: list[NativeLogObservation] = []
for index, state in enumerate(discoverer(log_directory, baseline, test_start_ns)):
text = reader(state, baseline)
analysis = parser(state, baseline).to_summary()
scenes = analysis.get("scenes")
scene_names = (
sorted(str(name) for name in scenes)
if isinstance(scenes, Mapping)
else []
)
captured_path = (
result_directory
/ "native-logs"
/ f"{index:02d}-{state.path.name}.current.log"
)
write_text(captured_path, text)
captures.append(
{
"source": str(state.path),
"state": serialize_state(state),
"captured_path": str(captured_path),
"current_run_bytes": len(text.encode("utf-8")),
"scene_names": scene_names,
"load_texture_to_material_failed": analysis.get(
"load_texture_to_material_failed",
False,
),
"asset_pack_health_failed": analysis.get(
"asset_pack_health_failed",
False,
),
"import_scene_threw": analysis.get("import_scene_threw", False),
}
)
candidates.append(
NativeLogObservation(
state=state,
text=text,
analysis=analysis,
failure_reason=None,
)
)
prioritizer = native_log_priority if prioritize is None else prioritize
selected = (
max(candidates, key=lambda item: prioritizer(item.state, item.analysis))
if candidates
else fallback
)
if failure_state is not None:
final_states = snapshotter(log_directory)
final_state = final_states.get(str(failure_state.path.absolute()))
if final_state is not None:
selected = NativeLogObservation(
state=final_state,
text=reader(final_state, baseline),
analysis=parser(final_state, baseline).to_summary(),
failure_reason=None,
)
if selected is not None:
write_text(result_directory / "native.log", selected.text)
return NativeLogSelection(observation=selected, captures=tuple(captures))
def native_log_priority(
state: LogFileState,
analysis: Mapping[str, Any],
) -> tuple[int, int, int, int, str]:
"""Rank scene-rich engine logs ahead of newer low-signal logs."""
scenes = analysis.get("scenes")
scene_count = 0
if isinstance(scenes, Mapping):
for imports in scenes.values():
if isinstance(imports, list):
scene_count += len(imports)
return (
int(scene_count > 0),
scene_count,
int(state.path.name.casefold().startswith("log_")),
state.mtime_ns,
str(state.path),
)
def scene_import_survived(
analysis: Mapping[str, Any] | None,
*,
timed_out: bool,
) -> bool:
"""Return whether the target scene completed without a captured failure."""
if analysis is None:
return False
return bool(
analysis.get("whispering_vale")
and not timed_out
and not analysis.get("load_texture_to_material_failed", False)
and not analysis.get("asset_pack_health_failed", False)
and not analysis.get("import_scene_threw", False)
and not analysis.get("failed_import_scenes", [])
)

View File

@@ -0,0 +1,18 @@
"""Stable result and error models for controlled diagnostic experiments."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class ExperimentResult:
result_directory: Path
summary: dict[str, Any]
exit_code: int
class ExperimentError(RuntimeError):
"""Raised for a controlled experiment setup or lifecycle failure."""

View File

@@ -0,0 +1,624 @@
"""Safe testcase preparation for controlled diagnostic experiments."""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
from typing import Any
from ..config import AppConfig, project_root
from ..filesystem import sha256_file
from ..ini import IniError, inspect_ini_value
from ..prefix import (
PrefixSafetyError,
cleanup_dedicated_wineserver,
validate_dedicated_prefix,
)
from ..registry import DesktopRegistrySnapshot
from ..result import ResultDirectory
from ..runner import CommandLogger
from ..testcase import TestIniOverride, TestModification, Testcase
from .cleanup import (
registry_restore_commands as _registry_restore_commands_impl,
registry_setup_commands as _registry_setup_commands_impl,
require_command as _require_command,
validate_disposable_path as _validate_disposable_path,
)
from .models import ExperimentError
_WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
_DISPOSABLE_DIRECTORY_NAME = "ptdiag-disposable"
_PERSISTENT_STATE_CACHE_SENTINEL = "@ptdiag-persistent"
_PERSISTENT_STATE_CACHE_DIRECTORY = "ptdiag-cache/dxvk-state"
def _resource_copy_paths(
live_game: Path,
modification: TestModification,
) -> tuple[Path, Path]:
if modification.kind not in {"copy-file", "normalize-assbin-textures"}:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
root = live_game.resolve(strict=True)
source_relative = _safe_resource_relative_path(modification.source)
destination_relative = _safe_resource_relative_path(modification.destination)
source = root.joinpath(*source_relative.parts).resolve(strict=True)
try:
source.relative_to(root)
except ValueError as exc:
raise ExperimentError("Modification source escapes the live game tree") from exc
if not source.is_file():
raise ExperimentError(f"Modification source is not a file: {source}")
destination = root.joinpath(*destination_relative.parts)
destination_parent = destination.parent.resolve(strict=True)
try:
destination_parent.relative_to(root)
except ValueError as exc:
raise ExperimentError("Modification destination escapes the live game tree") from exc
if modification.kind == "copy-file" and (destination.exists() or destination.is_symlink()):
raise ExperimentError(
f"Live modification destination already exists; refusing experiment: {destination}"
)
if modification.kind == "normalize-assbin-textures":
if source_relative != destination_relative:
raise ExperimentError(
"Assbin normalization source and destination must be the same disposable file"
)
if not modification.texture_indices:
raise ExperimentError("Assbin normalization requires texture_indices")
if not destination.is_file():
raise ExperimentError(f"Assbin normalization target is not a file: {destination}")
return source, destination
def _safe_resource_relative_path(value: str) -> PurePosixPath:
if "\\" in value:
raise ExperimentError("Modification paths must use forward slashes")
path = PurePosixPath(value)
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
raise ExperimentError(f"Unsafe relative resource path: {value!r}")
return path
def _prepare_disposable_game(
config: AppConfig,
result: ResultDirectory,
logger: CommandLogger,
modification: TestModification,
disposable_game: Path,
source: Path,
) -> dict[str, Any]:
staging_root = (config.paths.app_root / _DISPOSABLE_DIRECTORY_NAME).absolute()
_validate_disposable_path(staging_root, disposable_game, result.path.name)
if disposable_game.exists() or disposable_game.is_symlink():
raise ExperimentError(f"Disposable game path already exists: {disposable_game}")
usage = shutil.disk_usage(staging_root.parent)
copy_bytes = _allocated_tree_bytes(config.paths.live_game)
if usage.free < copy_bytes + 2 * 1024**3:
raise ExperimentError(
f"Insufficient free space for disposable game copy: "
f"need at least {copy_bytes + 2 * 1024**3}, have {usage.free}"
)
_require_command(
logger.run(
["mkdir", "-p", "--", str(staging_root)],
timeout_seconds=15.0,
),
"create disposable staging directory",
)
_require_command(
logger.run(
[
"cp",
"-a",
"--reflink=auto",
"--sparse=always",
f"{config.paths.live_game}/.",
str(disposable_game),
],
timeout_seconds=900.0,
),
"copy disposable game tree",
)
destination_relative = _safe_resource_relative_path(modification.destination)
destination = disposable_game.joinpath(*destination_relative.parts)
if modification.kind == "copy-file":
if destination.exists() or destination.is_symlink():
raise ExperimentError(
f"Disposable destination unexpectedly existed before copy: {destination}"
)
_require_command(
logger.run(
["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)],
timeout_seconds=30.0,
),
"copy selected resource",
)
normalization: dict[str, Any] | None = None
destination_existed_before = False
elif modification.kind == "normalize-assbin-textures":
if not destination.is_file() or destination.is_symlink():
raise ExperimentError(
f"Disposable Assbin target is missing or unsafe: {destination}"
)
if sha256_file(destination) != sha256_file(source):
raise ExperimentError("Disposable Assbin differs from live source before transform")
temporary_output = destination.with_name(
f".{destination.name}.{result.path.name}.normalized"
)
if temporary_output.exists() or temporary_output.is_symlink():
raise ExperimentError(f"Normalization temporary output exists: {temporary_output}")
normalize_result = logger.run(
[
sys.executable,
"-m",
"ptdiag",
"scene",
"normalize-textures",
str(destination),
str(temporary_output),
*(str(index) for index in modification.texture_indices),
],
cwd=project_root(),
timeout_seconds=240.0,
)
_require_command(normalize_result, "normalize embedded Assbin textures")
try:
normalization = json.loads(normalize_result.stdout)
except json.JSONDecodeError as exc:
raise ExperimentError("Normalizer returned invalid JSON") from exc
if not isinstance(normalization, dict) or not normalization.get("ok"):
raise ExperimentError("Normalizer did not report a verified result")
_require_command(
logger.run(
["mv", "--", str(temporary_output), str(destination)],
timeout_seconds=30.0,
),
"install normalized Assbin in disposable tree",
)
destination_existed_before = True
else:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
destination_hash = sha256_file(destination)
source_hash = sha256_file(source)
if modification.kind == "copy-file" and destination_hash != source_hash:
raise ExperimentError("Copied resource hash differs from its source")
if modification.kind == "normalize-assbin-textures" and destination_hash == source_hash:
raise ExperimentError("Normalized Assbin hash did not change")
ini_changes = _apply_disposable_ini_overrides(
disposable_game,
modification.ini_overrides,
logger,
)
return {
"staging_root": str(staging_root),
"free_bytes_before": usage.free,
"source_allocated_bytes": copy_bytes,
"destination_path": str(destination),
"destination_existed_before": destination_existed_before,
"destination_sha256_after": destination_hash,
"destination_size_after": destination.stat().st_size,
"normalization": normalization,
"ini_changes": ini_changes,
}
def _inspect_ini_overrides(
game_root: Path,
overrides: Sequence[TestIniOverride],
) -> list[dict[str, Any]]:
root = game_root.resolve(strict=True)
result: list[dict[str, Any]] = []
for override in overrides:
path = _safe_game_file(root, override.path)
state = inspect_ini_value(path, override.section, override.key)
result.append(
{
"path": str(path),
"section": override.section,
"key": override.key,
"expected": override.expected,
"value": override.value,
"current": state["value"],
"matches_expected": state["value"] == override.expected,
"matches_value": state["value"] == override.value,
"sha256": state["sha256"],
"size": state["size"],
"mtime_ns": state["mtime_ns"],
}
)
return result
def _apply_disposable_ini_overrides(
disposable_game: Path,
overrides: Sequence[TestIniOverride],
logger: CommandLogger,
) -> list[dict[str, Any]]:
root = disposable_game.resolve(strict=True)
result: list[dict[str, Any]] = []
for override in overrides:
path = _safe_game_file(root, override.path)
outcome = logger.run(
_ini_override_command(path, override),
cwd=project_root(),
timeout_seconds=30.0,
)
_require_command(outcome, f"override [{override.section}] {override.key}")
try:
payload = json.loads(outcome.stdout)
except json.JSONDecodeError as exc:
raise ExperimentError("INI override returned invalid JSON") from exc
if not isinstance(payload, dict) or not payload.get("ok"):
raise ExperimentError("INI override did not report a verified result")
result.append(payload)
return result
def _safe_game_file(root: Path, relative: str) -> Path:
parts = _safe_resource_relative_path(relative).parts
path = root.joinpath(*parts)
resolved = path.resolve(strict=True)
try:
resolved.relative_to(root)
except ValueError as exc:
raise ExperimentError(f"INI path escapes game tree: {relative}") from exc
if path.is_symlink() or not resolved.is_file():
raise ExperimentError(f"INI path is missing or unsafe: {path}")
return resolved
def _ini_override_command(path: Path, override: TestIniOverride) -> list[str]:
return [
sys.executable,
"-m",
"ptdiag",
"ini",
"override",
str(path),
override.section,
override.key,
override.expected,
override.value,
]
def _allocated_tree_bytes(root: Path) -> int:
total = 0
for directory, _, filenames in os.walk(root, followlinks=False):
for filename in filenames:
path = Path(directory) / filename
try:
total += path.lstat().st_blocks * 512
except FileNotFoundError:
continue
return total
def _log_modification_plan(
logger: CommandLogger,
config: AppConfig,
disposable_game: Path,
source: Path,
modification: TestModification,
) -> None:
staging_root = disposable_game.parent
destination = disposable_game.joinpath(
*_safe_resource_relative_path(modification.destination).parts
)
prefix_link = config.proton7.prefix / "drive_c/Program Files (x86)/Pristontale EU"
commands: list[tuple[list[str], float]] = [
(["mkdir", "-p", "--", str(staging_root)], 15.0),
(
[
"cp",
"-a",
"--reflink=auto",
"--sparse=always",
f"{config.paths.live_game}/.",
str(disposable_game),
],
900.0,
),
]
if modification.kind == "copy-file":
commands.append(
(["cp", "--preserve=mode,timestamps", "--", str(source), str(destination)], 30.0)
)
elif modification.kind == "normalize-assbin-textures":
temporary_output = destination.with_name(f".{destination.name}.planned.normalized")
commands.extend(
(
(
[
sys.executable,
"-m",
"ptdiag",
"scene",
"normalize-textures",
str(destination),
str(temporary_output),
*(str(index) for index in modification.texture_indices),
],
240.0,
),
(["mv", "--", str(temporary_output), str(destination)], 30.0),
)
)
else:
raise ExperimentError(f"Unsupported modification kind: {modification.kind}")
for override in modification.ini_overrides:
ini_path = disposable_game.joinpath(
*_safe_resource_relative_path(override.path).parts
)
commands.append((_ini_override_command(ini_path, override), 30.0))
commands.extend(
(
(["ln", "-sfn", "--", str(disposable_game), str(prefix_link)], 15.0),
(["ln", "-sfn", "--", os.readlink(prefix_link), str(prefix_link)], 15.0),
(["rm", "-rf", "--one-file-system", "--", str(disposable_game)], 900.0),
)
)
for command, timeout in commands:
logger.run(command, timeout_seconds=timeout, dry_run=True)
def _preflight(
config: AppConfig,
testcase: Testcase,
environment: Mapping[str, Any],
hashes: list[dict[str, Any]],
*,
game_processes: Callable[[], list[int]],
) -> list[str]:
errors: list[str] = []
if not environment.get("ok"):
errors.append("Required environment checks failed")
if any(not item.get("ok", False) for item in hashes if item.get("required", False)):
errors.append("A required baseline hash is missing or mismatched")
if testcase.runner != "proton7":
errors.append(f"Unsupported runner: {testcase.runner}")
if testcase.game.args != config.game.args:
errors.append("Testcase game arguments differ from the known PTEU_REBORN baseline")
if testcase.game.cwd != "live_game":
errors.append("Only the verified live_game working directory is supported")
if testcase.display.size != config.game.virtual_desktop:
errors.append("Testcase desktop size differs from the 1600x900 baseline")
if testcase.capture.strace_files and shutil.which("strace") is None:
errors.append("scene-strace requires strace")
if not testcase.watch.require_new_native_log:
errors.append("Runtime tests must require a current-run native log")
try:
prefix = validate_dedicated_prefix(config)
except (OSError, ValueError, PrefixSafetyError) as exc:
errors.append(str(exc))
else:
linked_game = prefix / "drive_c/Program Files (x86)/Pristontale EU"
try:
if linked_game.resolve(strict=True) != config.paths.live_game.resolve(strict=True):
errors.append("Dedicated prefix game link does not resolve to live_game")
except OSError as exc:
errors.append(f"Dedicated prefix game link is invalid: {exc}")
active = game_processes()
if active:
errors.append(f"Game.exe is already running: {active}")
return errors
def _dry_run_commands(
config: AppConfig,
testcase: Testcase,
logger: CommandLogger,
registry_before: DesktopRegistrySnapshot,
launch_argv: list[str],
env_overrides: Mapping[str, str],
) -> None:
cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True)
if testcase.display.virtual_desktop:
for command in _registry_setup_commands(
config, registry_before.desktop_name, testcase.display.size
):
logger.run(
command,
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
timeout_seconds=15.0,
dry_run=True,
)
logger.run(
launch_argv,
cwd=config.paths.live_game,
env_overrides=env_overrides,
timeout_seconds=testcase.timeout_seconds,
dry_run=True,
)
cleanup_dedicated_wineserver(config, logger, timeout_seconds=15.0, dry_run=True)
if testcase.display.virtual_desktop:
for command in _registry_restore_commands(config, registry_before):
logger.run(
command,
cwd=config.paths.live_game,
env_overrides=_proton_environment(config),
timeout_seconds=15.0,
dry_run=True,
)
def _launch_argv(config: AppConfig, testcase: Testcase, result_directory: Path) -> list[str]:
game = f"{_WINDOWS_GAME_DIRECTORY}\\{config.game.exe}"
proton = _proton_command(config, "runinprefix", game, *testcase.game.args)
if not testcase.capture.strace_files:
return proton
raw_directory = result_directory / "raw-strace"
raw_directory.mkdir(exist_ok=False)
strace = shutil.which("strace") or "/usr/bin/strace"
return [
strace,
"-ff",
"-qq",
"-ttt",
"-s",
"1024",
"-e",
"trace=%file",
"-o",
str(raw_directory / "trace"),
*proton,
]
def _proton_command(config: AppConfig, *arguments: str) -> list[str]:
shim = project_root() / "ptlinux/proton_compat.py"
return [
sys.executable,
str(shim),
str(config.proton7.proton),
*arguments,
]
def _runtime_environment(
config: AppConfig,
testcase: Testcase,
result_directory: Path,
) -> dict[str, str]:
environment = _proton_environment(config)
environment.update(testcase.env)
if environment.get("DXVK_STATE_CACHE_PATH") == _PERSISTENT_STATE_CACHE_SENTINEL:
environment["DXVK_STATE_CACHE_PATH"] = str(
(config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute()
)
environment.update(
{
"DXVK_LOG_PATH": str(result_directory),
"PROTON_LOG": "0",
"STEAM_COMPAT_INSTALL_PATH": str(config.paths.live_game),
"SteamAppId": "0",
"SteamGameId": "0",
}
)
return environment
def _state_cache_plan(
config: AppConfig,
environment: Mapping[str, str],
) -> dict[str, Any] | None:
configured = environment.get("DXVK_STATE_CACHE_PATH")
if configured is None:
return None
cache_directory = Path(configured).expanduser().absolute()
cache_file = cache_directory / f"{Path(config.game.exe).stem}.dxvk-cache"
return {
"directory": str(cache_directory),
"cache_file": str(cache_file),
"persistent": True,
"seed_source": str(config.paths.live_game / cache_file.name),
"seeded": False,
"before": _state_cache_file_snapshot(cache_file),
"after": None,
}
def _prepare_state_cache(
config: AppConfig,
environment: Mapping[str, str],
logger: CommandLogger,
) -> dict[str, Any]:
plan = _state_cache_plan(config, environment)
if plan is None:
raise ExperimentError("Persistent DXVK state-cache plan is missing")
cache_directory = Path(str(plan["directory"]))
expected = (config.paths.app_root / _PERSISTENT_STATE_CACHE_DIRECTORY).absolute()
if cache_directory != expected:
raise ExperimentError(
f"Refusing unmanaged DXVK state-cache directory: {cache_directory}; "
f"expected {expected}"
)
_require_command(
logger.run(
["mkdir", "-p", "--", str(cache_directory)],
timeout_seconds=15.0,
),
"create managed DXVK state-cache directory",
)
cache_file = Path(str(plan["cache_file"]))
seed_source = Path(str(plan["seed_source"]))
if not cache_file.exists() and seed_source.is_file():
_require_command(
logger.run(
[
"cp",
"--preserve=mode,timestamps",
"--",
str(seed_source),
str(cache_file),
],
timeout_seconds=30.0,
),
"seed managed DXVK state cache",
)
plan["seeded"] = True
plan["before"] = _state_cache_file_snapshot(cache_file)
return plan
def _state_cache_file_snapshot(path: Path) -> dict[str, Any]:
result: dict[str, Any] = {
"path": str(path),
"exists": path.is_file(),
"size": None,
"mtime_ns": None,
"sha256": None,
}
if not result["exists"]:
return result
try:
state = path.stat()
result.update(
{
"size": state.st_size,
"mtime_ns": state.st_mtime_ns,
"sha256": sha256_file(path),
}
)
except OSError as exc:
result["error"] = str(exc)
return result
def _proton_environment(config: AppConfig) -> dict[str, str]:
return {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(config.proton7.steam_root),
"STEAM_COMPAT_DATA_PATH": str(config.proton7.compat),
"WINEPREFIX": str(config.proton7.prefix),
"WINEDEBUG": "-all",
}
def _registry_setup_commands(
config: AppConfig,
desktop_name: str,
desktop_size: str,
) -> list[list[str]]:
return _registry_setup_commands_impl(
config,
desktop_name,
desktop_size,
proton_command=_proton_command,
)
def _registry_restore_commands(
config: AppConfig,
snapshot: DesktopRegistrySnapshot,
) -> list[list[str]]:
return _registry_restore_commands_impl(
config,
snapshot,
proton_command=_proton_command,
)

40
src/ptdiag/logs.py Normal file
View File

@@ -0,0 +1,40 @@
"""Compatibility façade for launcher-owned native game-log parsing."""
from ptlinux.diagnostics.native_logs import (
DEFAULT_NATIVE_LOG_PATTERNS,
AssetReferenceReport,
LogEvent,
LogFileState,
NativeLogAnalysis,
SceneImport,
classify_asset_references,
discover_current_native_logs,
is_current_run_log,
newest_current_native_log,
normalize_asset_reference,
parse_current_run_log,
parse_native_log,
parse_native_log_lines,
read_current_run_log_text,
snapshot_native_logs,
)
__all__ = [
"DEFAULT_NATIVE_LOG_PATTERNS",
"AssetReferenceReport",
"LogEvent",
"LogFileState",
"NativeLogAnalysis",
"SceneImport",
"classify_asset_references",
"discover_current_native_logs",
"is_current_run_log",
"newest_current_native_log",
"normalize_asset_reference",
"parse_current_run_log",
"parse_native_log",
"parse_native_log_lines",
"read_current_run_log_text",
"snapshot_native_logs",
]

View File

@@ -11,7 +11,7 @@ from .filesystem import sha256_file
_VALUE_RE = re.compile(r'^(?P<name>"(?:\\.|[^"\\])*")=(?P<value>.*)$')
_SECTION_RE = re.compile(r"^\[(?P<name>.*)\](?:\s+[0-9]+)?$")
_SECTION_RE = re.compile(r"^\[(?P<name>.*)](?:\s+[0-9]+)?$")
@dataclass(frozen=True)

View File

@@ -7,7 +7,12 @@ from pathlib import Path
from typing import Any, Mapping
import tomllib
from .config import project_root
from .config import repository_root
DEFAULT_TESTCASE_DIRECTORY = Path("diagnostics/testcases")
LEGACY_TESTCASE_DIRECTORY = Path("testcases")
_PATH_ENVIRONMENT_KEYS = frozenset({"DXVK_CONFIG_FILE"})
class TestcaseError(ValueError):
@@ -29,18 +34,12 @@ class TestDisplay:
@dataclass(frozen=True)
class TestWatch:
require_new_native_log: bool
patterns: tuple[str, ...]
@dataclass(frozen=True)
class TestCapture:
process_maps: bool
cwd: bool
environment: bool
dll_overrides: bool
hashes: bool
dxvk_log: bool
native_log: bool
strace_files: bool
performance_stats: bool
focus_before_seconds: float
@@ -92,7 +91,15 @@ def resolve_testcase_path(value: str | Path) -> Path:
path = Path(value)
if path.suffix or path.parent != Path("."):
return path.expanduser().absolute()
return (project_root() / "testcases" / f"{path.name}.toml").absolute()
root = repository_root()
filename = f"{path.name}.toml"
preferred = root / DEFAULT_TESTCASE_DIRECTORY / filename
legacy = root / LEGACY_TESTCASE_DIRECTORY / filename
if preferred.is_file():
return preferred.absolute()
if legacy.is_file():
return legacy.absolute()
return preferred.absolute()
def load_testcase(value: str | Path) -> Testcase:
@@ -100,7 +107,10 @@ def load_testcase(value: str | Path) -> Testcase:
data, sources = _load_inherited(path, ())
game = _section(data, "game")
display = _section(data, "display")
environment = _section(data, "env")
environment = _resolve_environment_paths(
_string_mapping(_section(data, "env"), "env"),
path.parent,
)
watch = _section(data, "watch")
capture = _section(data, "capture")
modification = _optional_section(data, "modification")
@@ -120,19 +130,13 @@ def load_testcase(value: str | Path) -> Testcase:
virtual_desktop=_boolean(display, "virtual_desktop"),
size=_string(display, "size"),
),
env=_string_mapping(environment, "env"),
env=environment,
watch=TestWatch(
require_new_native_log=_boolean(watch, "require_new_native_log"),
patterns=_string_tuple(watch, "patterns"),
),
capture=TestCapture(
process_maps=_boolean(capture, "process_maps"),
cwd=_boolean(capture, "cwd"),
environment=_boolean(capture, "environment"),
dll_overrides=_boolean(capture, "dll_overrides"),
hashes=_boolean(capture, "hashes"),
dxvk_log=_boolean(capture, "dxvk_log"),
native_log=_boolean(capture, "native_log"),
strace_files=_boolean(capture, "strace_files"),
performance_stats=_optional_boolean(capture, "performance_stats", False),
focus_before_seconds=_optional_nonnegative_number(
@@ -161,6 +165,25 @@ def load_testcase(value: str | Path) -> Testcase:
)
def _resolve_environment_paths(
environment: Mapping[str, str],
testcase_directory: Path,
) -> dict[str, str]:
result = dict(environment)
for key in _PATH_ENVIRONMENT_KEYS:
value = result.get(key)
if value is None:
continue
path = Path(value).expanduser()
if path.is_absolute():
result[key] = str(path)
continue
candidate = (testcase_directory / path).resolve(strict=False)
if candidate.is_file():
result[key] = str(candidate)
return result
def _load_inherited(
path: Path,
stack: tuple[Path, ...],

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.6.7"
__version__ = "0.9.11"

75
src/ptlinux/__main__.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import sys
def main() -> int:
"""Dispatch the common GUI path without importing every CLI operation."""
arguments = sys.argv[1:]
if not arguments or arguments == ["gui"]:
from .container_host import (
container_host_error,
repair_container_host_after_update,
)
from .paths import AppPaths
from .telemetry import initialize_shared_telemetry, shutdown_shared_telemetry
from .umu import provision_umu_for_startup
telemetry = None
telemetry_owner = False
try:
paths = AppPaths.from_environment()
telemetry, telemetry_owner = initialize_shared_telemetry(
paths,
session_metadata={"interface": "gui", "command": "gui"},
)
# Older installed releases cannot run a bootstrap step that only exists
# in their update payload. Their automatic restart enters here instead.
provision_umu_for_startup(paths)
container_host = repair_container_host_after_update(paths)
if container_host.repair_attempted and (
not container_host.ready
or container_host.broken_umu_profile_link
or container_host.repair_error is not None
):
print(
f"Warning: {container_host_error(container_host)}",
file=sys.stderr,
)
from .ui import run_dashboard
run_dashboard(paths)
except KeyboardInterrupt:
return 130
except (ValueError, OSError, RuntimeError) as exc:
if telemetry is not None:
telemetry.report_exception(
"launcher_exception",
exc,
metadata={"command": "gui"},
context={"operation": "launcher", "stage": "startup"},
)
print(f"Error: {exc}", file=sys.stderr)
return 1
except BaseException as exc:
if telemetry is not None:
telemetry.report_exception(
"launcher_exception",
exc,
metadata={"command": "gui"},
context={"operation": "launcher", "stage": "startup"},
severity="critical",
)
raise
finally:
if telemetry is not None and telemetry_owner:
shutdown_shared_telemetry(telemetry)
return 0
from .cli import main as cli_main
return cli_main(arguments, _manage_telemetry=True)
raise SystemExit(main())

View File

@@ -0,0 +1,2 @@
"""Application workflows shared by the CLI and dashboard entry points."""

View File

@@ -0,0 +1,2 @@
"""Feature-oriented command workflows."""

View File

@@ -0,0 +1,190 @@
"""Host health, runtime bootstrap, and login diagnostic commands."""
from __future__ import annotations
from dataclasses import asdict
import sys
from typing import Any
from ...container_host import ContainerHostReport
from ...launcher import LauncherError
from ...paths import AppPaths
def doctor(api: Any, as_json: bool) -> int:
report = api.inspect_host()
if as_json:
api._print_json(report.to_dict())
else:
print(f"Distribution: {report.distribution} {report.version} ({report.architecture})")
print(f"Display: {report.display_backend}")
for tool in report.tools:
status = tool.path or "MISSING"
required = "required" if tool.required else "optional"
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
optional_missing = [
tool.name for tool in report.tools if not tool.required and tool.path is None
]
if optional_missing:
print(
"Optional tools missing (these do not block launch): "
+ ", ".join(optional_missing)
)
print(f"Host readiness: {'READY' if report.ok else 'BLOCKED'}")
if report.blockers:
print("Blocking diagnostics:")
for blocker in report.blockers:
print(f" - {blocker}")
for warning in report.warnings:
print(f"Warning: {warning}")
if report.package_hint and any(
tool.required and tool.path is None for tool in report.tools
):
print(report.package_hint)
return 0 if report.ok else 1
def container_host(api: Any, repair: bool, as_json: bool) -> int:
report = api.repair_container_host() if repair else api.inspect_container_host()
ok = container_host_ok(report)
payload = {"ok": ok, "container_host": report.to_dict()}
if as_json:
api._print_json(payload)
else:
status = "ready" if report.ready else "blocked"
print(f"Bubblewrap: {status} ({report.bwrap_path or 'not found'})")
if report.active_profile_source:
print(f"AppArmor: {report.active_profile_source}")
elif report.apparmor_restrict_unprivileged_userns == "1":
print(
"AppArmor: unprivileged user namespaces are restricted; "
"no bwrap profile found"
)
if report.repair_actions:
for action in report.repair_actions:
print(f"Repair: {action}")
if report.repair_error:
print(f"Repair failed: {report.repair_error}", file=sys.stderr)
elif report.recommendation:
print(f"Action: {report.recommendation}", file=sys.stderr)
return 0 if ok else 1
def container_host_ok(report: ContainerHostReport) -> bool:
return (
report.ready
and not report.broken_umu_profile_link
and report.repair_error is None
)
def container_host_startup_migration(api: Any, paths: AppPaths) -> None:
report = api.repair_container_host_after_update(paths)
if report.repair_attempted and not container_host_ok(report):
print(f"Warning: {api.container_host_error(report)}", file=sys.stderr)
def runtime_bootstrap(api: Any, paths: AppPaths, as_json: bool) -> int:
api._print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…")
result = api.provision_umu(paths)
executable = api.find_umu_launcher(paths)
if executable is None:
raise LauncherError("the Steam Runtime helper is still unavailable")
api._emit(
{
"ok": True,
"runtime_bootstrap": {
"executable": executable,
"managed_download": asdict(result) if result else None,
},
},
as_json,
)
return 0
def login_diagnostics(
api: Any,
paths: AppPaths,
timeout_seconds: float,
runtime_choice: str,
as_json: bool,
) -> int:
settings = api._load_or_default_settings(paths, persist=False)
runner = api._runner(paths, "login-diagnostics", False)
with api._operation_guard(paths, "login-diagnostics", False):
report = api.inspect_login_environment(
paths,
settings,
timeout_seconds=timeout_seconds,
runner=runner,
runtime_override=(
None if runtime_choice == "configured" else runtime_choice
),
)
if as_json:
api._print_json(report.to_dict())
else:
probe = report.login_server
print(f"Launcher: {report.launcher_version} ({report.launcher_module})")
print(f"Python: {report.python_executable}")
print(f"Host: {report.distribution_id} ({report.kernel_release})")
print(
"Runtime test: "
f"{report.runtime_requested} "
f"(configured: {report.runtime_configured}; settings unchanged)"
)
status = "REACHABLE" if probe.reachable else "UNREACHABLE"
latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else ""
print(f"Login server: {status} ({probe.host}:{probe.port}{latency})")
if probe.error:
print(f"Network error: {probe.error}")
print("Authentication: not tested; no credentials or game-protocol data were sent")
print(f"Login responses: {report.login_response_ownership}")
identity_status = (
"ready"
if report.client_identity_ready is True
else "NOT READY"
if report.client_identity_ready is False
else "not probed"
)
print(f"Client identity: {identity_status}; raw identity values were not collected")
print(f"Privacy: {report.client_identity_privacy}")
if not report.login_trace_enabled:
print("Login trace: retired (will not launch or instrument the game)")
print(f"Publisher test: {report.publisher_comparison_command}")
print(f"Game profile: {report.game_profile}")
print(f"Game.exe: {report.game_exe}")
if report.game_exe_sha256:
print(f"Game SHA-256: {report.game_exe_sha256}")
print(f"game.dll: {report.game_dll}")
if report.game_dll_sha256:
print(f"DLL SHA-256: {report.game_dll_sha256}")
print(
"Managed source: "
+ ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS")
)
if report.depot_build is not None:
print(
f"Depot: build {report.depot_build} "
f"({report.depot_version or 'unknown version'})"
)
if report.runtime_kind:
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
else:
print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})")
if report.proton_sync_effective is not None:
print(
"Proton sync: "
f"{report.proton_sync_configured} -> {report.proton_sync_effective}"
)
prefix_status = "ready" if report.prefix_ready else "not ready"
directx_status = "ready" if report.directx_ready else "not ready"
print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}")
print(f"Prefix path: {report.prefix or 'unavailable'}")
print(f"Latest log: {report.latest_game_log or 'not found'}")
if report.latest_game_log_modified_at:
print(f"Log modified: {report.latest_game_log_modified_at}")
for warning in report.warnings:
print(f"Warning: {warning}")
return 0 if report.preflight_ok else 1

View File

@@ -0,0 +1,479 @@
"""Download, setup, depot synchronization, import, and prepare commands."""
from __future__ import annotations
from dataclasses import asdict
import json
from pathlib import Path
import time
from typing import Any
from ...command import CommandOutcome, CommandRunner
from ...depot import DownloadOptions, SyncProgress
from ...importer import GameImportResult
from ...launcher import LauncherError, WineTools
from ...paths import AppPaths
from ...settings import Settings
def fetch(api: Any, paths: AppPaths, dry_run: bool, as_json: bool) -> int:
if not dry_run:
paths.create_base_directories()
if dry_run:
value = {
"ok": True,
"dry_run": True,
"url": api.OFFICIAL_LAUNCHER_URL,
"download": str(paths.launcher_pristine),
"prepared": str(paths.launcher_patched),
}
else:
telemetry = api.current_telemetry()
download_started_at = time.monotonic()
if telemetry is not None:
telemetry.send_event(
"download_started",
metadata={
"resource_type": "launcher",
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
try:
download = api.fetch_official_launcher(
paths.launcher_pristine,
paths.launcher_metadata,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"download_failed",
severity="error",
message=str(exc),
metadata={
"resource_type": "launcher",
"duration_ms": round(
(time.monotonic() - download_started_at) * 1000
),
"failure_type": api.classify_network_failure(exc),
"http_status": api.network_http_status(exc),
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
raise
download_duration = max(0.0, time.monotonic() - download_started_at)
download_bytes = download.size if download.changed else 0
if telemetry is not None:
telemetry.send_event(
"download_completed",
metadata={
"resource_type": "launcher",
"download_bytes": download_bytes,
"content_bytes": download.size,
"duration_ms": round(download_duration * 1000),
"average_mbps": (
round((download_bytes * 8) / download_duration / 1_000_000, 3)
if download_bytes and download_duration > 0
else None
),
"retry_count": 0,
"resume_used": False,
"http_status": 200 if download.changed else 304,
"changed": download.changed,
"mirror_id": "pristontale.eu",
},
context={"operation": "download", "stage": "launcher_download"},
)
validation_started_at = time.monotonic()
try:
patch = api.prepare_launcher_binary(
paths.launcher_pristine,
paths.launcher_patched,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"file_validation_failed",
severity="error",
message=str(exc),
metadata={
"file_category": "launcher",
"duration_ms": round(
(time.monotonic() - validation_started_at) * 1000
),
},
context={"operation": "download", "stage": "validation"},
)
raise
value = {
"ok": True,
"download": asdict(download),
"patch": asdict(patch),
"warning": None if patch.supported_profile else (
"The launcher is newer than the verified font-patch profile; "
"it was retained byte-for-byte."
),
}
api._emit(value, as_json)
return 0
def setup(
api: Any,
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
as_json: bool,
) -> int:
tools = api.find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = api._runner(paths, "setup", dry_run)
with api._operation_guard(paths, "setup", dry_run):
results = api._configure_launcher_runtime(
paths,
tools,
runner,
force_runtime=force_runtime,
)
api._emit(
{
"ok": True,
"dry_run": dry_run,
"prefix": str(paths.launcher_prefix),
"commands": [asdict(result) for result in results],
},
as_json,
)
return 0
def native_update(
api: Any,
paths: AppPaths,
dry_run: bool,
repair: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
"""Use the native verified depot client for normal installs and updates."""
settings = api._load_or_default_settings(paths, persist=not dry_run)
download_options = api._configured_download_options(settings)
runner = api._runner(paths, "native-update", dry_run)
with api._operation_guard(paths, "native-update", dry_run):
api._print_operation_status(as_json, 1, 3, "Checking local game files…")
game_import = None
if not no_import:
game_import = api._maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
verify_import = game_import is not None or api.game_import_requires_verification(paths)
api._print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
result = api.sync_managed_game(
paths,
repair=repair or verify_import,
quarantine_unmanaged=verify_import,
dry_run=dry_run,
progress=None if as_json else api._print_sync_progress,
**(
{"download_options": download_options}
if download_options is not None
else {}
),
)
if verify_import and not dry_run:
api.mark_game_import_verified(
paths,
build=result.build,
files_quarantined=result.files_quarantined,
quarantine_directory=result.quarantine_directory,
)
overlay = None
if not dry_run and paths.source_game.is_dir():
api._print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
overlay = asdict(api.prepare_runtime_view(paths, paths.source_game))
api._emit(
{
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"native_update": asdict(result),
"runtime_view": overlay,
},
as_json,
)
return 0
def wine_update(
api: Any,
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
api._fetch(paths, dry_run, True)
tools = api.find_wine_tools()
if tools is None:
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
runner = api._runner(paths, "update", dry_run)
settings = api._load_or_default_settings(paths, persist=not dry_run)
download_options = api._configured_download_options(settings)
with api._operation_guard(paths, "update", dry_run):
api._configure_launcher_runtime(
paths,
tools,
runner,
force_runtime=force_runtime,
)
game_import = None
if not no_import:
game_import = api._maybe_import_game(
paths,
settings,
seed_from,
runner,
announce=not as_json,
)
updater = api.launch_official_updater(paths, tools, runner)
native_verification = None
verify_import = not dry_run and (
game_import is not None or api.game_import_requires_verification(paths)
)
if verify_import:
native_verification = api.sync_managed_game(
paths,
repair=True,
quarantine_unmanaged=True,
progress=None if as_json else api._print_sync_progress,
**(
{"download_options": download_options}
if download_options is not None
else {}
),
)
api.mark_game_import_verified(
paths,
build=native_verification.build,
files_quarantined=native_verification.files_quarantined,
quarantine_directory=native_verification.quarantine_directory,
)
overlay = None
if not dry_run and paths.source_game.is_dir():
overlay = asdict(api.prepare_runtime_view(paths, paths.source_game))
value = {
"ok": True,
"dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None,
"updater": asdict(updater),
"native_verification": (
asdict(native_verification) if native_verification is not None else None
),
"runtime_view": overlay,
}
api._emit(value, as_json)
return 0
def configure_launcher_runtime(
api: Any,
paths: AppPaths,
tools: WineTools,
runner: CommandRunner,
*,
force_runtime: bool,
) -> list[CommandOutcome]:
if not force_runtime or runner.dry_run:
return api.configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=force_runtime,
)
telemetry = api.current_telemetry()
started_at = time.monotonic()
metadata = {
"runtime_type": "dotnet_desktop",
"runtime_version": "6",
}
if telemetry is not None:
telemetry.send_event(
"runtime_reinstall_requested",
metadata={**metadata, "reason": "force_runtime"},
context={"operation": "runtime_repair", "stage": "requested"},
)
telemetry.send_event(
"runtime_repair_started",
metadata=metadata,
context={"operation": "runtime_repair", "stage": "started"},
)
try:
results = api.configure_launcher_prefix(
paths,
tools,
runner,
force_runtime=True,
)
except BaseException as exc:
if telemetry is not None:
telemetry.send_event(
"runtime_repair_failed",
severity="error",
message=str(exc),
metadata={
**metadata,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "runtime_repair", "stage": "runtime_install"},
)
raise
if telemetry is not None:
telemetry.send_event(
"runtime_repair_success",
metadata={
**metadata,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "runtime_repair", "stage": "completed"},
)
return results
def print_sync_progress(progress: SyncProgress) -> None:
"""Emit line-delimited progress that the native Tk dashboard can consume."""
print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True)
def configured_download_options(settings: Settings) -> DownloadOptions | None:
if (
settings.download_speed_limit_bytes_per_second is None
and not settings.low_bandwidth_mode
):
return None
return DownloadOptions(
bandwidth_limit_bytes_per_second=(
settings.download_speed_limit_bytes_per_second
),
low_bandwidth=settings.low_bandwidth_mode,
)
def print_operation_status(
as_json: bool,
step: int,
total: int,
message: str,
) -> None:
"""Emit a structured operation stage for the native launcher dashboard."""
if not as_json:
print(
f"STATUS {json.dumps({'step': step, 'total': total, 'message': message})}",
flush=True,
)
def import_game(
api: Any,
paths: AppPaths,
source: Path,
dry_run: bool,
as_json: bool,
) -> int:
runner = api._runner(paths, "import-game", dry_run)
if not as_json:
print(f"Importing existing game from {source.expanduser()}…", flush=True)
with api._operation_guard(paths, "import-game", dry_run):
result = api._import_with_telemetry(paths, source, runner)
api._emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json)
return 0
def maybe_import_game(
api: Any,
paths: AppPaths,
settings: Settings,
seed_from: Path | None,
runner: CommandRunner,
*,
announce: bool,
) -> GameImportResult | None:
if paths.source_game.exists() or paths.source_game.is_symlink():
if seed_from is not None:
raise RuntimeError(
f"cannot seed an existing managed game directory: {paths.source_game}"
)
return None
candidate = seed_from
if candidate is None:
candidate = api.discover_existing_game(paths, settings)
if candidate is None:
return None
if announce:
print(f"Bootstrapping from existing game: {candidate}", flush=True)
return api._import_with_telemetry(paths, candidate, runner)
def import_with_telemetry(
api: Any,
paths: AppPaths,
source: Path,
runner: CommandRunner,
) -> GameImportResult:
telemetry = api.current_telemetry()
started_at = time.monotonic()
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_started",
context={"operation": "import", "stage": "copy"},
)
try:
result = api.import_existing_game(paths, source, runner)
except BaseException as exc:
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_failed",
severity="error",
message=str(exc),
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "import", "stage": "copy"},
)
raise
if telemetry is not None and not runner.dry_run:
telemetry.send_event(
"import_success",
metadata={
"files_imported": result.files,
"bytes_imported": result.bytes,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "import", "stage": "completed"},
)
return result
def prepare(api: Any, paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
with api._operation_guard(paths, "prepare", False):
if game_dir is None and api.game_import_requires_verification(paths):
raise LauncherError(
"the imported game has not completed its full publisher verification; "
"run pristontale-eu update"
)
settings = api.ensure_settings(paths)
source = api._source_game(paths, settings, game_dir)
result = api.prepare_runtime_view(paths, source)
api._emit({"ok": True, "runtime_view": asdict(result)}, as_json)
return 0

View File

@@ -0,0 +1,138 @@
"""Launcher self-update command workflow."""
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
import sys
from typing import Any
from ...paths import AppPaths
from ...umu import UmuBootstrapResult
from ...update_resolver import STABLE_CHANNEL
def launcher_update(
api: Any,
paths: AppPaths,
check_only: bool,
verify: bool,
rollback: str | bool | None,
manifest_url: str | None,
public_key: Path | None,
as_json: bool,
*,
history_url: str | None = None,
) -> int:
try:
selected_channel = api.load_settings(paths).update_channel
except (OSError, TypeError, ValueError):
# Keep update recovery usable when settings are absent or damaged.
selected_channel = STABLE_CHANNEL
rollback_requested = rollback is not None and rollback is not False
if rollback_requested:
target_version = rollback if isinstance(rollback, str) and rollback else None
result = api.rollback_launcher_update(
paths,
target_version=target_version,
selected_channel=selected_channel,
history_url=history_url,
public_key_path=public_key,
)
api._emit(
{
"ok": True,
"launcher_rollback": asdict(result),
},
as_json,
)
return 0
if check_only:
check = api.check_for_launcher_update(
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._emit(
{
"ok": True,
"launcher_update": api.update_check_to_dict(check),
},
as_json,
)
return 0
if verify:
api._print_operation_status(as_json, 1, 2, "Authenticating the launcher release…")
result = api.verify_launcher_update_channel(
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._print_operation_status(as_json, 2, 2, "Published launcher release verified.")
api._emit(
{
"ok": True,
"launcher_update_verification": asdict(result),
},
as_json,
)
return 0
api._print_operation_status(as_json, 1, 4, "Verifying the signed launcher release…")
result = api.install_launcher_update(
paths,
manifest_url=manifest_url,
public_key_path=public_key,
channel=selected_channel,
)
api._print_operation_status(
as_json,
2,
4,
"Launcher update installed; preparing the Steam Runtime helper…",
)
umu_result: UmuBootstrapResult | None = None
umu_executable: str | None = None
umu_warning: str | None = None
try:
umu_result = api.provision_umu(paths)
umu_executable = api.find_umu_launcher(paths)
except (OSError, RuntimeError) as exc:
# Activation is already durable. Keep the update successful so the GUI can
# restart into the new release, whose startup hook and Play path both retry.
warning_message = (
"the launcher update was installed, but the Steam Runtime helper could "
f"not be prepared yet: {exc}"
)
umu_warning = warning_message
if not as_json:
print(f"Warning: {warning_message}", file=sys.stderr)
api._print_operation_status(
as_json,
3,
4,
"Checking Steam Runtime sandbox permissions (authentication may be requested)…",
)
container_report = api.repair_container_host()
container_warning = container_report.repair_error
if not container_report.ready and container_warning is None:
container_warning = container_report.recommendation
if container_warning and not as_json:
print(f"Warning: {container_warning}", file=sys.stderr)
api._print_operation_status(as_json, 4, 4, "Launcher update and runtime check complete.")
api._emit(
{
"ok": True,
"launcher_update": asdict(result),
"runtime_bootstrap": {
"executable": umu_executable,
"managed_download": asdict(umu_result) if umu_result else None,
"warning": umu_warning,
},
"container_host": {
**container_report.to_dict(),
"warning": container_warning,
},
},
as_json,
)
return 0

View File

@@ -0,0 +1,938 @@
"""Game launch, runtime outcome, log, and crash reporting commands."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import asdict, replace
from datetime import datetime
from pathlib import Path
import re
import signal
import sys
import threading
import time
from typing import Any
from uuid import uuid4
from ...command import CommandOutcome
from ...diagnostics.native_logs import LogFileState
from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
from ...launcher import LauncherError
from ...login_trace import LOGIN_TRACE_DISABLED_REASON
from ...paths import AppPaths
from ...settings import Settings
from ...umu import UmuBootstrapResult
from ...window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
)
def play(
api: Any,
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
) -> int:
with api._play_guard(paths, dry_run) as play_lease:
return api._play_locked(
paths,
game_dir,
no_box,
dry_run,
skip_runtime_setup,
as_json,
realm=realm,
reuse_prepared=not play_lease.preparation_allowed,
before_game=play_lease.allow_shared_clients,
game_lease_fds=play_lease.inherited_fds,
)
def play_locked(
api: Any,
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
reuse_prepared: bool = False,
before_game: Callable[[], None] | None = None,
game_lease_fds: Sequence[int] = (),
) -> int:
telemetry = api.current_telemetry()
launch_requested_at = time.monotonic()
window_diagnostics_active = (
telemetry is not None
and not dry_run
and bool(getattr(telemetry, "enabled", True))
)
launch_session_id = uuid4().hex if window_diagnostics_active else None
launch_session_metadata = (
{"launch_session_id": launch_session_id}
if launch_session_id is not None
else {}
)
if telemetry is not None and not dry_run:
telemetry.send_event(
"game_launch_requested",
metadata=launch_session_metadata,
context={"operation": "launch_game", "stage": "preflight"},
)
if game_dir is None and api.game_import_requires_verification(paths):
raise LauncherError(
"the imported game has not completed its full publisher verification; "
"run pristontale-eu update before playing"
)
settings = api._load_or_default_settings(paths, persist=not dry_run)
launcher_nested_in_steam_runtime = api.is_running_inside_steam_runtime()
if launcher_nested_in_steam_runtime and not dry_run:
print(
"Warning: the launcher itself is running inside Steam Runtime. Start "
"it from the desktop menu or a host terminal; nesting its UMU runtime "
"can cause black game output.",
file=sys.stderr,
flush=True,
)
window_launch_metadata: dict[str, object] = {
"gamebox_enabled": not no_box,
"resizable_window_requested": settings.allow_window_resizing,
"launcher_inside_steam_runtime": launcher_nested_in_steam_runtime,
}
if (
launcher_nested_in_steam_runtime
and settings.allow_window_resizing
and not no_box
):
window_launch_metadata["gamescope_disabled_reason"] = (
"launcher_inside_steam_runtime"
)
start_option = api.select_depot_start_option(
api.read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
realm or settings.realm,
)
settings = runtime_settings_for_realm(settings, start_option.name)
source = api._source_game(paths, settings, game_dir)
overlay = None
depot_file_catalog = None
depot_preflight = None
if game_dir is None and not dry_run:
depot_file_catalog = api.read_current_depot_file_catalog(paths)
api._print_operation_status(
as_json,
1,
4,
(
"Reusing the active managed game view…"
if reuse_prepared
else "Preparing the managed game view…"
),
)
if reuse_prepared:
if game_dir is not None:
raise LauncherError(
"an additional client cannot replace the active managed game view; "
"close every client before using --game-dir"
)
if not paths.runtime_view.is_dir():
raise LauncherError(
"another game client is active, but the prepared runtime view is missing"
)
elif not dry_run:
if game_dir is None:
if depot_file_catalog is not None:
depot_preflight = api.preflight_managed_game(
paths,
catalog=depot_file_catalog,
)
if depot_preflight is not None and not depot_preflight.ready:
details = "; ".join(
f"{issue.path}: {issue.reason}"
for issue in depot_preflight.issues[:3]
)
raise LauncherError(
"the managed game integrity check found "
f"{depot_preflight.issue_count} missing or changed file(s)"
+ (f" ({details})" if details else "")
+ "; run `pristontale-eu update --repair` before playing"
)
overlay = api.prepare_runtime_view(paths, source)
api._print_operation_status(as_json, 2, 4, "Ensuring the compatibility runtime is available…")
umu_bootstrap = (
None
if reuse_prepared
else api._ensure_portable_umu_for_play(paths, settings, dry_run=dry_run)
)
selection = api.select_runtime(settings, paths=paths)
runner = api._runner(paths, "play", dry_run)
setup_results = []
if reuse_prepared:
api._print_operation_status(
as_json,
3,
4,
"Reusing the active Proton and DirectX runtime…",
)
if not api.game_runtime_ready(paths, selection):
raise LauncherError(
"another game client is active, but its prepared runtime no longer "
"matches the current settings; close every client and launch again"
)
elif not skip_runtime_setup:
api._print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
setup_results = api.prepare_game_runtime(paths, selection, runner)
else:
api._print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
api._print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
native_log_directory = paths.game_save / "logs"
native_log_baseline = api.snapshot_native_logs(
native_log_directory,
patterns=("log_*.txt",),
)
crash_directory = paths.game_save / "crash"
crash_baseline = api.snapshot_native_logs(
crash_directory,
patterns=("*.dat", "*.dmp"),
)
game_started_ns = time.time_ns()
game_started_at = time.monotonic()
launch_success_lock = threading.Lock()
# CommandRunner keeps a bounded output tail. Preserve readiness here so a
# long, noisy session cannot evict the evidence and become a false failure.
observed_readiness_signal: str | None = None
launch_success_reported = False
reported_runtime_warnings: set[str] = set()
if telemetry is not None and not dry_run:
telemetry.send_event(
"game_launch_started",
metadata={
**launch_session_metadata,
**window_launch_metadata,
"launch_duration_ms": round(
(game_started_at - launch_requested_at) * 1000
),
"launch_stage": "game_start",
"runtime": selection.kind,
"realm": start_option.name,
},
context={"operation": "launch_game", "stage": "game_start"},
)
if before_game is not None:
before_game()
managed_asset_paths = (
[entry.path for entry in depot_file_catalog.files]
if depot_file_catalog is not None
else None
)
def observe_game_output(line: str) -> str | None:
"""Timestamp launch success when the supervisor first confirms readiness."""
nonlocal observed_readiness_signal, launch_success_reported
presentation_failure = api._gamescope_presentation_failure(line)
if telemetry is not None and not dry_run and presentation_failure is not None:
with launch_success_lock:
if presentation_failure not in reported_runtime_warnings:
reported_runtime_warnings.add(presentation_failure)
telemetry.send_event(
"game_window_diagnostic",
severity="warning",
metadata={
**launch_session_metadata,
**window_launch_metadata,
"component": "gamescope_host",
"diagnostic": presentation_failure,
"runtime": selection.kind,
"realm": start_option.name,
},
context={
"operation": "launch_game",
"stage": "presentation",
},
)
window_diagnostic = api.parse_window_diagnostic(line)
if window_diagnostic is not None:
diagnostic_session_id = window_diagnostic.get("session_id")
if launch_session_id is None or diagnostic_session_id != launch_session_id:
return line
if telemetry is not None and window_diagnostics_active:
with launch_success_lock:
telemetry.send_event(
"game_window_diagnostic",
severity=api._window_diagnostic_severity(window_diagnostic),
metadata={
"runtime": selection.kind,
"realm": start_option.name,
**window_diagnostic,
},
context={
"operation": "launch_game",
"stage": "window_diagnostics",
},
)
return None
live_readiness_signal = api._game_readiness_signal(line)
if live_readiness_signal is None:
return line
with launch_success_lock:
if observed_readiness_signal is None:
observed_readiness_signal = live_readiness_signal
if telemetry is None or dry_run or launch_success_reported:
return line
ready_at = time.monotonic()
launch_success_reported = telemetry.send_event(
"game_launch_success",
metadata={
**launch_session_metadata,
**window_launch_metadata,
"launch_duration_ms": round(
(ready_at - launch_requested_at) * 1000
),
"game_start_duration_ms": round(
(ready_at - game_started_at) * 1000
),
"game_window_ready": True,
"readiness_signal": observed_readiness_signal,
"runtime": selection.kind,
"realm": start_option.name,
},
context={"operation": "launch_game", "stage": "ready"},
)
return line
try:
outcome = api.run_game(
paths,
settings,
selection,
runner,
start_option=start_option,
use_gamebox=not no_box,
concurrent_prefix_launch=reuse_prepared,
check=False,
output_line_filter=observe_game_output,
extra_environment=(
{
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: launch_session_id,
}
if launch_session_id is not None
else None
),
pass_fds=game_lease_fds,
)
except KeyboardInterrupt:
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
current_game_log = api._current_game_log(
native_log_directory,
native_log_baseline,
game_started_ns,
managed_asset_paths=managed_asset_paths,
source_game=source if managed_asset_paths is not None else None,
)
launch_evidence = observed_readiness_signal
if launch_evidence is None and current_game_log is not None:
launch_evidence = "game_log"
if telemetry is not None and not dry_run:
api._report_game_launch_cancelled(
telemetry,
launch_requested_at,
stage="running" if launch_evidence is not None else "game_start",
metadata={
**launch_session_metadata,
**window_launch_metadata,
"runtime_seconds": round(game_runtime_seconds),
"clean_exit": False,
"launch_succeeded": launch_evidence is not None,
"game_window_ready": observed_readiness_signal is not None,
"readiness_signal": launch_evidence,
"session_duration_class": api._session_duration_class(
game_runtime_seconds
),
"runtime": selection.kind,
"realm": start_option.name,
},
)
if current_game_log is not None and not as_json:
api._print_interrupted_game_log(current_game_log)
raise api._GameLaunchInterruptionReported from None
current_game_log = api._current_game_log(
native_log_directory,
native_log_baseline,
game_started_ns,
managed_asset_paths=managed_asset_paths,
source_game=source if managed_asset_paths is not None else None,
)
current_game_crash = api._current_game_crash(
crash_directory,
crash_baseline,
game_started_ns,
)
runtime_issues = api._runtime_issue_summary(outcome.stderr)
retained_readiness_signal = api._game_readiness_signal(outcome.stderr)
game_readiness_signal = observed_readiness_signal or retained_readiness_signal
game_window_ready = game_readiness_signal is not None
runtime_issues["game_window_ready"] = game_window_ready
if game_readiness_signal is not None:
runtime_issues["game_readiness_signal"] = game_readiness_signal
if (
selection.kind == "proton7-container"
and runtime_issues["host_futex_fatal_count"]
):
runtime_issues["recommended_one_variable_comparison"] = "proton7"
elif selection.kind == "proton7" and runtime_issues["gstreamer_missing_libraries"]:
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
runtime_issues["recommended_one_variable_comparison"] = "umu"
clean_exit = outcome.returncode == 0 and current_game_crash is None
launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None:
launch_evidence = "game_log"
elif launch_evidence is None and clean_exit:
launch_evidence = "clean_process_exit"
launch_succeeded = launch_evidence is not None
report = {
"ok": clean_exit,
"launch_succeeded": launch_succeeded,
"dry_run": dry_run,
"runtime": asdict(selection),
"umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None,
"realm": asdict(start_option),
"runtime_view": asdict(overlay) if overlay else None,
"depot_preflight": (
{
**asdict(depot_preflight),
"ready": depot_preflight.ready,
"fully_verified": depot_preflight.fully_verified,
}
if depot_preflight
else None
),
"setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome),
"current_game_log": current_game_log,
"current_game_crash": current_game_crash,
"runtime_issues": runtime_issues,
}
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
if telemetry is not None and not dry_run:
common_game_metadata: dict[str, object] = {
**launch_session_metadata,
**window_launch_metadata,
"runtime_seconds": round(game_runtime_seconds),
"clean_exit": clean_exit,
"timed_out": outcome.timed_out,
"launch_succeeded": launch_succeeded,
"game_window_ready": game_window_ready,
"readiness_signal": launch_evidence,
"crash_dump": current_game_crash is not None,
"session_duration_class": api._session_duration_class(game_runtime_seconds),
"runtime": selection.kind,
"realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind),
}
if launch_succeeded:
if not launch_success_reported:
assert launch_evidence is not None
telemetry.send_event(
"game_launch_success",
metadata={
**launch_session_metadata,
**window_launch_metadata,
"detection_duration_ms": round(game_runtime_seconds * 1000),
"detected_after_exit": True,
"game_window_ready": game_window_ready,
"readiness_signal": launch_evidence,
"runtime": selection.kind,
"realm": start_option.name,
},
context={"operation": "launch_game", "stage": "post_exit"},
)
else:
telemetry.send_event(
"game_launch_failed",
severity="error",
message=(
"Game client wrote a crash dump"
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
else f"Game client exited with status {outcome.returncode}"
),
metadata={
**common_game_metadata,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
},
context={"operation": "launch_game", "stage": "game_start"},
)
if launch_succeeded:
exit_message = None
if current_game_crash is not None:
exit_message = "Game client wrote a crash dump"
elif outcome.timed_out:
exit_message = "Game session timed out"
elif common_game_metadata.get("signal_inferred") is True:
exit_message = (
"Game session ended with status "
f"{common_game_metadata['raw_exit_code']}, consistent with "
f"{common_game_metadata['signal_name']} wrapped by UMU"
)
elif "signal" in common_game_metadata:
exit_message = (
"Game session was terminated by "
f"{common_game_metadata['signal_name']}"
)
elif outcome.returncode not in {0, None}:
exit_message = f"Game session ended with status {outcome.returncode}"
telemetry.send_event(
"game_exited",
severity=(
"error"
if current_game_crash is not None
else "info" if clean_exit else "warning"
),
message=exit_message,
metadata=(
common_game_metadata
if clean_exit
else {
**common_game_metadata,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
}
),
context={"operation": "launch_game", "stage": "exited"},
)
if as_json or dry_run:
api._emit(report, as_json)
else:
api._print_game_outcome(
outcome,
current_game_log,
runtime_issues,
current_game_crash,
)
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
def session_duration_class(duration_seconds: float) -> str:
if duration_seconds < 30:
return "under_30_seconds"
if duration_seconds < 5 * 60:
return "under_5_minutes"
return "normal"
def game_exit_metadata(
returncode: int | None,
runtime_kind: str,
) -> dict[str, object]:
metadata: dict[str, object] = {"exit_code": returncode}
if returncode is None:
return metadata
if returncode < 0:
signal_number = -returncode
metadata.update(
{
"exit_code": 128 + min(signal_number, 127),
"raw_exit_code": returncode,
"signal": signal_number,
"signal_name": signal_name(signal_number),
}
)
return metadata
# UMU returns Popen's negative child status from a Python main function.
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it.
wrapped_sigkill = 256 - int(signal.SIGKILL)
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill:
metadata.update(
{
"exit_code": 128 + int(signal.SIGKILL),
"raw_exit_code": returncode,
"signal": int(signal.SIGKILL),
"signal_name": signal.SIGKILL.name,
"signal_inferred": True,
"signal_inference": "umu_python_system_exit",
}
)
return metadata
def signal_name(signal_number: int) -> str:
try:
return signal.Signals(signal_number).name
except ValueError:
return f"signal {signal_number}"
def ensure_portable_umu_for_play(
api: Any,
paths: AppPaths,
settings: Settings,
*,
dry_run: bool,
) -> UmuBootstrapResult | None:
"""Provide a pinned UMU when the selected policy may use a container."""
if settings.runtime == "proton7":
return None
if dry_run:
return None
try:
return api.provision_umu(paths)
except (OSError, RuntimeError) as exc:
if settings.runtime != "auto":
raise LauncherError(
f"the verified portable UMU runtime helper could not be installed: {exc}"
) from exc
try:
api.select_runtime(replace(settings, runtime="proton7"), paths=paths)
except RuntimeErrorPT:
raise LauncherError(
"no usable game runtime is available and the verified portable UMU "
f"runtime helper could not be installed: {exc}"
) from exc
print(
"Warning: the Steam Runtime helper could not be installed; falling back "
f"to direct Proton 7 for this launch ({exc}).",
file=sys.stderr,
)
return None
def current_game_log(
api: Any,
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
*,
managed_asset_paths: Sequence[str] | None = None,
source_game: Path | None = None,
) -> dict[str, object] | None:
state = api.newest_current_native_log(
directory,
baseline,
started_ns,
patterns=("log_*.txt",),
)
if state is None:
return None
analysis = api.parse_current_run_log(state, baseline)
asset_references = api.classify_asset_references(
analysis,
managed_asset_paths,
source_game=source_game,
)
return {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"size": state.size,
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
"import_scene_threw": analysis.import_scene_threw,
"asset_pack_health_failed": analysis.asset_pack_health_failed,
"managed_asset_integrity_failed": asset_references.managed_integrity_failed,
"asset_references": asset_references.to_dict(),
"runtime_out_of_memory": analysis.runtime_out_of_memory,
"out_of_memory_count": len(analysis.out_of_memory_events),
"out_of_memory_effect_paths": analysis.out_of_memory_effect_paths,
"effect_creation_failures": [
event.to_dict() for event in analysis.effect_creation_failures
],
"failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage,
"module_code_sync_error": analysis.module_code_sync_error,
"module_code_sync_left": analysis.module_code_sync_left,
"module_code_sync_right": analysis.module_code_sync_right,
"module_code_sync_delta": analysis.module_code_sync_delta,
"banned_code_1": analysis.banned_code_1,
"banned_code_1_in_native_log": analysis.banned_code_1,
"banned_code_1_observation_scope": (
"native-game-log-only; server-provided login-screen text is not "
"written to this log"
),
"failed_file_find_count": analysis.failed_file_find_count,
"failed_file_load_count": analysis.failed_file_load_count,
"failed_file_read_count": analysis.failed_file_read_count,
"failed_asset_file_count": analysis.failed_asset_file_count,
"failed_texture_file_count": analysis.failed_texture_file_count,
"failed_effect_file_count": analysis.failed_effect_file_count,
"missing_texture_reference_count": analysis.missing_texture_reference_count,
"missing_effect_reference_count": analysis.missing_effect_reference_count,
"missing_image_reference_count": analysis.missing_image_reference_count,
"missing_pack_image_reference_count": analysis.missing_pack_image_reference_count,
"stage_without_new_stage_events": [
event.to_dict()
for event in analysis.events
if event.kind == "stage_without_new_stage"
],
"scene_imports": {
name: [scene.to_dict() for scene in imports]
for name, imports in sorted(analysis.scenes.items())
},
"failed_file_count": len(analysis.failed_files),
"failed_files": [event.to_dict() for event in analysis.failed_files[:64]],
}
def current_game_crash(
api: Any,
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
) -> dict[str, object] | None:
state = api.newest_current_native_log(
directory,
baseline,
started_ns,
patterns=("*.dat", "*.dmp"),
)
if state is None:
return None
return {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"size": state.size,
}
def runtime_issue_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
futex_message = "the futex facility returned an unexpected error code"
wrong_elf_message = "wrong elf class: elfclass64"
dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred"
bwrap_uid_map_denied = "bwrap: setting up uid map: permission denied"
missing_libraries = sorted(
{
library
for line in folded.splitlines()
if "gstreamer-warning" in line and "failed to load plugin" in line
for library in re.findall(
r"([a-z0-9_.+-]+\.so(?:\.\d+)*): cannot open shared object file",
line,
)
}
)
return {
"game_window_ready": game_window_was_ready(folded),
"host_futex_fatal_count": folded.count(futex_message),
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
"gstreamer_missing_library_count": len(missing_libraries),
"gstreamer_missing_libraries": missing_libraries,
"gstreamer_stream_error": "winegstreamer error" in folded,
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
"gamescope_present_queue_unsupported": (
"physical device queue doesn't support presenting on our surface" in folded
),
"gamescope_vulkan_output_failed_count": folded.count(
"vulkan_make_output failed"
),
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
"bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded,
}
def gamescope_presentation_failure(output: str) -> str | None:
folded = output.casefold()
for diagnostic, marker in (
(
"gamescope_present_queue_unsupported",
"physical device queue doesn't support presenting on our surface",
),
("gamescope_vulkan_output_failed", "vulkan_make_output failed"),
):
if marker in folded:
return diagnostic
return None
def game_window_was_ready(stderr: str) -> bool:
return game_readiness_signal(stderr) is not None
def game_readiness_signal(output: str) -> str | None:
folded = output.casefold()
for signal_name, marker in (
("window_ready", "game window ready:"),
("rendered_frame", "rendered game frame:"),
(
"window_supervision",
"game window resizing is disabled to prevent repeated direct3d device resets",
),
):
if marker in folded:
return signal_name
return None
def window_diagnostic_severity(diagnostic: dict[str, object]) -> str:
name = diagnostic.get("diagnostic")
details = diagnostic.get("details")
if isinstance(name, str) and (
name.endswith(("_failed", "_unavailable"))
or name in {
"host_hidden_state_cleared",
"host_inactive_remap",
"host_minimize_latched",
}
):
return "warning"
if isinstance(details, dict):
if name == "host_scaling_configured" and not all(
details.get(key) is True
for key in (
"resize_increments_applied",
"scaling_enabled",
"compositing_preferred",
)
):
return "warning"
if name == "scaling_configured" and details.get("enabled") is not True:
return "warning"
return "debug"
def print_game_outcome(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
runtime_issues: dict[str, object],
current_game_crash: dict[str, object] | None = None,
) -> None:
# Keep the human-facing stream concise. CommandRunner already relayed every
# process line live; replaying its capture here made each Proton failure appear
# twice in the dashboard.
if current_game_crash is not None:
crash_path = current_game_crash.get("path")
crash_path_text = crash_path if isinstance(crash_path, str) else "unknown"
print(
f"Game client crashed and wrote a dump: {crash_path_text}",
file=sys.stderr,
)
elif outcome.timed_out:
print("Game client timed out and was stopped.", file=sys.stderr)
elif outcome.returncode == 0:
print("Game client exited normally.")
elif outcome.returncode is None:
print("Game client exited without a status.", file=sys.stderr)
else:
print(f"Game client exited with status {outcome.returncode}.", file=sys.stderr)
if current_game_log is not None:
print_interrupted_game_log(current_game_log)
wrong_elf_count = runtime_issues["gstreamer_wrong_elf_class_count"]
if isinstance(wrong_elf_count, int) and wrong_elf_count:
print(
"The repeated GStreamer wrong-ELF-class messages are plugin-scan noise; "
"they do not mean that Proton itself failed to start.",
file=sys.stderr,
)
missing_libraries = runtime_issues["gstreamer_missing_libraries"]
if isinstance(missing_libraries, list) and missing_libraries:
print(
"Media plugins could not load these shared libraries: "
+ ", ".join(str(value) for value in missing_libraries)
+ ".",
file=sys.stderr,
)
assertion_count = runtime_issues["dbghelp_symdeferred_assertion_count"]
if isinstance(assertion_count, int) and assertion_count:
print(
"Proton 7's older Wine build hit its dbghelp SymDeferred assertion; this "
"can mask the client failure that occurred immediately before it.",
file=sys.stderr,
)
if runtime_issues["bubblewrap_uid_map_permission_denied"]:
print(
"Ubuntu denied Bubblewrap's UID map before Proton started. Run "
"`pristontale-eu container-host --repair` and retry.",
file=sys.stderr,
)
recommendation = runtime_issues.get("recommended_one_variable_comparison")
if isinstance(recommendation, str):
print(
f"Recommended single-variable runtime comparison: {recommendation}.",
file=sys.stderr,
)
def print_interrupted_game_log(report: dict[str, object]) -> None:
log_path = report.get("path")
log_path_text = log_path if isinstance(log_path, str) else "unknown"
print(f"\nCurrent game log: {log_path_text}", file=sys.stderr)
asset_references = report.get("asset_references")
catalog_available = (
asset_references.get("catalog_available")
if isinstance(asset_references, dict)
else False
)
if report["module_code_sync_error"]:
print(
"Detected a publisher module-integrity mismatch; the game was launched "
"with publisher-pristine code in this launcher version.",
file=sys.stderr,
)
elif report["runtime_out_of_memory"]:
effects = report.get("out_of_memory_effect_paths")
effect_text = ""
if isinstance(effects, list) and effects:
effect_text = " while creating " + ", ".join(str(path) for path in effects[:3])
print(
"Detected Direct3D resource exhaustion in the 32-bit game process"
+ effect_text
+ ". Restarting the client can recover temporarily; depot repair will not "
"fix this failure.",
file=sys.stderr,
)
elif report["managed_asset_integrity_failed"]:
print(
"The game reported an asset that belongs to the publisher manifest but is "
"missing from the managed installation.",
file=sys.stderr,
)
print(
"Run `pristontale-eu update --repair` and restart.",
file=sys.stderr,
)
elif report["asset_pack_health_failed"] and not catalog_available:
print(
"The game reported many unresolved asset paths, but no matching cached "
"publisher manifest was available to classify them. Run "
"`pristontale-eu update` to refresh the manifest cache.",
file=sys.stderr,
)
elif report["load_texture_to_material_failed"]:
print(
"Detected a texture/material failure while loading the game world.",
file=sys.stderr,
)
elif report["import_scene_threw"] or report["failed_import_scenes"]:
print("Detected a game-world scene import failure.", file=sys.stderr)
def login_trace(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
skip_runtime_setup: bool,
timeout_seconds: float,
as_json: bool,
) -> int:
# Preserve the command name so old instructions fail safely before touching
# the prefix, preparing the runtime view, or starting Game.exe.
_ = paths, game_dir, no_box, skip_runtime_setup, timeout_seconds, as_json
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)

View File

@@ -0,0 +1,32 @@
"""Launcher settings command workflow."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from ...paths import AppPaths
def settings(
api: Any,
paths: AppPaths,
initialize: bool,
reset: bool,
as_json: bool,
) -> int:
if reset:
settings = api.reset_settings(paths)
elif initialize:
settings = api.ensure_settings(paths)
else:
settings = api.load_settings(paths)
api._emit(
{
"ok": True,
"path": str(paths.settings_file),
"settings": asdict(settings),
},
as_json,
)
return 0

View File

@@ -0,0 +1,245 @@
"""Shared CLI telemetry, result reporting, and output helpers."""
from __future__ import annotations
import argparse
import json
import signal
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from ..command import CommandError
from ..paths import AppPaths
from ..telemetry import TelemetryService
CommandTelemetryEvents = tuple[str, str, str, str]
@dataclass(frozen=True, slots=True)
class CommandTelemetryLifecycle:
telemetry: TelemetryService | None
events: CommandTelemetryEvents | None
started_at: float
owner: bool
def start_command_telemetry(
api: Any,
paths: AppPaths,
command: str,
arguments: argparse.Namespace,
manage_telemetry: bool,
) -> CommandTelemetryLifecycle:
telemetry: TelemetryService | None = None
telemetry_owner = False
if manage_telemetry:
interface = "gui" if command == "gui" else "cli"
telemetry, telemetry_owner = api.initialize_shared_telemetry(
paths,
session_metadata={"interface": interface, "command": command},
report_launcher_lifecycle=command == "gui",
)
events = api._command_telemetry_events(command, arguments, paths)
started_at = time.monotonic()
if telemetry is not None and events is not None:
operation, started_event, _success_event, _failed_event = events
if operation == "repair":
telemetry.send_event(
"repair_requested",
metadata={"command": command, "reason": "user_requested"},
context={"operation": operation, "stage": "requested"},
)
telemetry.send_event(
started_event,
metadata={"command": command},
context={"operation": operation, "stage": "started"},
)
return CommandTelemetryLifecycle(
telemetry=telemetry,
events=events,
started_at=started_at,
owner=telemetry_owner,
)
def shutdown_command_telemetry(
api: Any,
lifecycle: CommandTelemetryLifecycle,
) -> None:
if lifecycle.telemetry is not None and lifecycle.owner:
api.shutdown_shared_telemetry(lifecycle.telemetry)
def command_telemetry_events(
command: str,
arguments: argparse.Namespace,
paths: AppPaths,
) -> CommandTelemetryEvents | None:
if bool(getattr(arguments, "dry_run", False)):
return None
if command in {"update", "sync", "wine-update"}:
if bool(getattr(arguments, "repair", False)):
return ("repair", "repair_started", "repair_success", "repair_failed")
if not paths.source_game.exists():
return ("install", "install_started", "install_success", "install_failed")
return ("update", "update_started", "update_success", "update_failed")
if command == "launcher-update" and not (
bool(getattr(arguments, "check", False))
or bool(getattr(arguments, "verify", False))
or getattr(arguments, "rollback", None) is not None
):
return (
"launcher_update",
"update_started",
"update_success",
"update_failed",
)
if command in {"setup", "runtime-bootstrap"}:
return (
"runtime_install",
"runtime_install_started",
"runtime_install_success",
"runtime_install_failed",
)
if command == "doctor":
return (
"compatibility_check",
"compatibility_check_started",
"compatibility_check_completed",
"compatibility_check_failed",
)
return None
def report_command_result(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
returncode: int,
) -> None:
if telemetry is None or events is None:
return
operation, _started_event, success_event, failed_event = events
duration_ms = round((time.monotonic() - started_at) * 1000)
telemetry.send_event(
success_event if returncode == 0 else failed_event,
severity="info" if returncode == 0 else "error",
message=None if returncode == 0 else f"Command exited with status {returncode}",
metadata={
"command": command,
"duration_ms": duration_ms,
"exit_code": returncode,
},
context={
"operation": operation,
"stage": "completed" if returncode == 0 else "command_exit",
},
)
def report_command_failure(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
message: str,
*,
stage: str = "failed",
) -> None:
if telemetry is None:
return
if command == "play":
telemetry.send_event(
"game_launch_failed",
severity="error",
message=message,
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "launch_game", "stage": stage},
)
return
if events is None:
return
operation, _started_event, _success_event, failed_event = events
telemetry.send_event(
failed_event,
severity="error",
message=message,
metadata={
"command": command,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": operation, "stage": stage},
)
def report_game_launch_cancelled(
telemetry: TelemetryService | None,
started_at: float,
*,
stage: str,
metadata: dict[str, object] | None = None,
) -> None:
if telemetry is None:
return
event_metadata: dict[str, object] = {
"duration_ms": round((time.monotonic() - started_at) * 1000),
"interrupted": True,
"launcher_exit_code": 130,
"signal": int(signal.SIGINT),
"signal_name": signal.SIGINT.name,
}
if metadata is not None:
event_metadata.update(metadata)
telemetry.send_event(
"game_launch_cancelled",
severity="info",
message="Game launch was cancelled",
metadata=event_metadata,
context={"operation": "launch_game", "stage": stage},
)
def failure_stage(command: str, exception: BaseException) -> str:
if isinstance(exception, ValueError):
return "configuration"
if isinstance(exception, CommandError):
return "subprocess"
message = str(exception).casefold()
if any(value in message for value in ("manifest", "cdn", "request failed")):
return "manifest"
if any(value in message for value in ("download", "incomplete response")):
return "download"
if any(value in message for value in ("sha-1", "checksum", "hash mismatch")):
return "validation"
if any(
value in message
for value in ("permission", "read-only", "cannot write", "writable")
):
return "filesystem"
if any(value in message for value in ("prefix", "wine", "proton", "umu")):
return "runtime_check"
return f"{command}_operation"
def emit(
value: object,
as_json: bool,
*,
json_printer: Callable[[object], None],
) -> None:
if as_json:
json_printer(value)
else:
if isinstance(value, dict) and value.get("ok") is True:
print("Done.")
print(json.dumps(value, indent=2, sort_keys=True))
def print_json(value: object) -> None:
print(json.dumps(value, indent=2, sort_keys=True))

332
src/ptlinux/changelog.py Normal file
View File

@@ -0,0 +1,332 @@
"""Official patch changelog support for the native Linux dashboard."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import json
import math
import os
from pathlib import Path
import re
from typing import Callable, Protocol
from urllib.request import Request, urlopen
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
PATCH_NEWS_URL = "https://pristontale.eu/news#"
MAX_PATCHES = 10
MAX_PATCH_BYTES = 2 * 1024 * 1024
MAX_PATCH_SECTIONS = 32
MAX_PATCH_CHANGES = 200
_MAX_PATCH_CANDIDATES = 100
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
class ChangelogError(RuntimeError):
"""Raised when the official patch feed cannot be parsed safely."""
class ChangelogResponse(Protocol):
"""The minimal response interface used by the changelog client."""
def read(self, size: int = -1) -> bytes: ...
def __enter__(self) -> ChangelogResponse: ...
def __exit__(
self,
exception_type: type[BaseException] | None,
exception: BaseException | None,
traceback: object | None,
) -> bool | None: ...
ChangelogOpener = Callable[..., ChangelogResponse]
@dataclass(frozen=True)
class PatchChange:
text: str
type_key: str
@property
def tag(self) -> str:
return patch_change_tag(self.type_key)
@dataclass(frozen=True)
class PatchSection:
title: str
items: tuple[PatchChange, ...]
@dataclass(frozen=True)
class PatchNote:
id: int
slug: str
title: str
version: str | None
tags: tuple[str, ...]
summary: str
sections: tuple[PatchSection, ...]
published_at: str | None
@property
def url(self) -> str:
return f"{PATCH_NEWS_URL}{self.slug}"
def patch_change_tag(type_key: str) -> str:
"""Turn API keys such as ``new-feature`` into compact UI tags."""
label = " ".join(type_key.replace("_", "-").split("-"))
return label.upper() if label else "CHANGE"
def fetch_patch_notes(
cache_path: Path,
*,
timeout_seconds: float = 15.0,
opener: ChangelogOpener = urlopen,
) -> tuple[PatchNote, ...]:
"""Fetch the ten latest patches, falling back to the last valid cache."""
request = Request(
PATCHES_ENDPOINT,
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
method="GET",
)
try:
response = opener(request, timeout=timeout_seconds)
with response:
payload = response.read(MAX_PATCH_BYTES + 1)
if len(payload) > MAX_PATCH_BYTES:
raise ChangelogError("official patch response exceeds the 2 MiB limit")
patches = _parse_patch_notes(payload)
_write_patch_cache(cache_path, patches)
return patches
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
return read_cached_patch_notes(cache_path)
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
"""Read the last valid patch feed without performing network I/O."""
try:
value = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return ()
if not isinstance(value, list):
return ()
patches: list[PatchNote] = []
for item in value[:MAX_PATCHES]:
patch = _patch_note_from_cache(item)
if patch is not None:
patches.append(patch)
return tuple(patches)
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
value = json.loads(payload.decode("utf-8-sig"))
if isinstance(value, dict):
value = value.get("data")
if not isinstance(value, list):
raise ChangelogError("official patch response is not a patch list")
parsed: list[tuple[float, int, PatchNote]] = []
for item in value[:_MAX_PATCH_CANDIDATES]:
result = _parse_patch_note(item)
if result is not None:
timestamp, patch = result
parsed.append((timestamp, patch.id, patch))
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
if not patches:
raise ChangelogError("official patch response contains no usable patches")
return patches
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
version = _clean_text(value.get("version"), 64, allow_none=True)
if value.get("version") is not None and version is None:
return None
timestamp = _timestamp(value.get("date"))
patch = PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=_parse_sections(value.get("changes")),
published_at=_format_timestamp(timestamp),
)
return timestamp if timestamp is not None else -1.0, patch
def _parse_tags(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
tags: list[str] = []
seen: set[str] = set()
for item in value[:16]:
tag = _clean_text(item, 48)
if tag is None or tag.casefold() in seen:
continue
seen.add(tag.casefold())
tags.append(tag)
return tuple(tags)
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
if not isinstance(value, dict):
return ()
sections_value = value.get("sections")
if not isinstance(sections_value, list):
return ()
sections: list[PatchSection] = []
changes_seen = 0
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
title = _clean_text(section_value.get("title"), 100) or "Changes"
items_value = section_value.get("items")
if not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if changes_seen >= MAX_PATCH_CHANGES:
break
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is None:
continue
items.append(
PatchChange(
text=text,
type_key=type_key or _type_key_from_title(title),
)
)
changes_seen += 1
if items:
sections.append(PatchSection(title=title, items=tuple(items)))
if changes_seen >= MAX_PATCH_CHANGES:
break
return tuple(sections)
def _patch_note_from_cache(value: object) -> PatchNote | None:
if not isinstance(value, dict):
return None
patch_id = value.get("id")
slug = _clean_text(value.get("slug"), 180)
title = _clean_text(value.get("title"), 200)
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
version = _clean_text(value.get("version"), 64, allow_none=True)
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
if (
isinstance(patch_id, bool)
or not isinstance(patch_id, int)
or slug is None
or _SLUG_PATTERN.fullmatch(slug) is None
or title is None
or summary is None
):
return None
sections_value = value.get("sections")
sections: list[PatchSection] = []
if isinstance(sections_value, list):
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
if not isinstance(section_value, dict):
continue
section_title = _clean_text(section_value.get("title"), 100)
items_value = section_value.get("items")
if section_title is None or not isinstance(items_value, list):
continue
items: list[PatchChange] = []
for item in items_value:
if not isinstance(item, dict):
continue
text = _clean_text(item.get("text"), 4000)
type_key = _clean_text(item.get("type_key"), 64)
if text is not None and type_key is not None:
items.append(PatchChange(text=text, type_key=type_key))
if items:
sections.append(PatchSection(section_title, tuple(items)))
return PatchNote(
id=patch_id,
slug=slug,
title=title,
version=version,
tags=_parse_tags(value.get("tags")),
summary=summary,
sections=tuple(sections),
published_at=published_at,
)
def _clean_text(
value: object,
maximum_length: int,
*,
allow_empty: bool = False,
allow_none: bool = False,
) -> str | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
return None
text = " ".join(value.split())
if len(text) > maximum_length or (not text and not allow_empty):
return None
return text
def _timestamp(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
timestamp = float(value)
return timestamp if math.isfinite(timestamp) else None
def _format_timestamp(value: float | None) -> str | None:
if value is None:
return None
try:
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
except (OverflowError, OSError, ValueError):
return None
def _type_key_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError:
temporary.unlink(missing_ok=True)

918
src/ptlinux/cli.py Normal file
View File

@@ -0,0 +1,918 @@
"""Command-line interface for installation, updates, repair, and play."""
from __future__ import annotations
import argparse
from contextlib import AbstractContextManager, nullcontext
from dataclasses import asdict, replace
from datetime import datetime
import json
from pathlib import Path
import re
import signal
import sys
import threading
from typing import Callable, Sequence
from uuid import uuid4
from .diagnostics.native_logs import (
LogFileState,
classify_asset_references,
newest_current_native_log,
parse_current_run_log,
snapshot_native_logs,
)
from . import __version__
from .application import common as _application_helpers
from .application.commands import health as _health_commands
from .application.commands import installation as _installation_commands
from .application.commands import launcher_update as _launcher_update_commands
from .application.commands import play as _play_commands
from .application.commands import settings as _settings_commands
from .command import CommandError, CommandOutcome, CommandRunner
from .container_host import (
ContainerHostReport,
container_host_error,
inspect_container_host,
repair_container_host,
repair_container_host_after_update,
)
from .depot import (
DownloadOptions,
SyncProgress,
read_cached_depot_launch_catalog,
read_current_depot_file_catalog,
preflight_managed_game,
select_depot_start_option,
sync_managed_game,
)
from .doctor import inspect_host
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
from .importer import GameImportResult, discover_existing_game, import_existing_game
from .import_state import game_import_requires_verification, mark_game_import_verified
from .launcher import (
LauncherError,
WineTools,
configure_launcher_prefix,
find_wine_tools,
launch_official_updater,
prepare_launcher_binary,
)
from .locking import (
LauncherPlayLease,
launcher_operation_lock,
launcher_play_lock,
managed_game_process_is_active,
)
from .login_diagnostics import inspect_login_environment
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
from .overlay import cache_compatibility_binaries, prepare_runtime_view
from .paths import AppPaths
from .runtime import (
RuntimeErrorPT,
game_runtime_ready,
prepare_game_runtime,
run_game,
select_runtime,
)
from .settings import Settings, ensure_settings, load_settings, reset_settings
from .self_update import (
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
update_check_to_dict,
verify_launcher_update_channel,
)
from .update_resolver import STABLE_CHANNEL
from .telemetry import (
classify_network_failure,
current_telemetry,
initialize_shared_telemetry,
network_http_status,
shutdown_shared_telemetry,
)
from .system_identity import is_running_inside_steam_runtime
from .umu import (
UmuBootstrapResult,
find_umu_launcher,
provision_umu,
provision_umu_for_startup,
)
from .window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
parse_window_diagnostic,
)
class _GameLaunchInterruptionReported(KeyboardInterrupt):
"""Mark an in-game interrupt whose cancellation telemetry was already sent."""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pristontale-eu",
description="PristonTale EU all-in-one Linux installer and clean launcher",
)
# Python 3.14 enables ANSI-colored argparse output by default. Keep the CLI's
# help and error text stable for redirected output and downstream tooling.
if hasattr(parser, "color"):
parser.color = False
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("gui", help="open the native Linux dashboard")
subparsers.add_parser("doctor", help="check cross-distribution dependencies")
container_host = subparsers.add_parser(
"container-host",
help="check Bubblewrap and Ubuntu AppArmor for the Steam Runtime",
)
container_host.add_argument(
"--repair",
action="store_true",
help="repair only recognized Ubuntu AppArmor profile states",
)
subparsers.add_parser(
"runtime-bootstrap",
help="download and verify the Steam Runtime launcher helper",
)
login_diagnostics = subparsers.add_parser(
"login-diagnostics",
help="check the client, runtime, and publisher login route without credentials",
)
login_diagnostics.add_argument(
"--timeout",
type=float,
default=5.0,
help="TCP connection timeout in seconds (default: 5)",
)
login_diagnostics.add_argument(
"--runtime",
choices=("configured", "auto", "proton7", "proton7-container", "umu"),
default="configured",
help=(
"probe one runtime without changing settings or starting the game "
"(default: configured)"
),
)
fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher")
fetch.add_argument("--dry-run", action="store_true")
setup = subparsers.add_parser("setup", help="configure Wine and .NET Desktop for the updater")
setup.add_argument("--dry-run", action="store_true")
setup.add_argument("--force-runtime", action="store_true")
update = subparsers.add_parser(
"update",
help="safely synchronize the official game depot without Wine",
)
update.add_argument("--dry-run", action="store_true")
update.add_argument("--repair", action="store_true", help="hash-check every game file")
import_options = update.add_mutually_exclusive_group()
import_options.add_argument(
"--seed-from",
type=Path,
help="import an existing game before updating",
)
import_options.add_argument(
"--no-import",
action="store_true",
help="disable automatic existing-game discovery",
)
sync = subparsers.add_parser(
"sync",
help="native official depot synchronization without bootstrap discovery",
)
sync.add_argument("--dry-run", action="store_true")
sync.add_argument("--repair", action="store_true", help="hash-check every game file")
legacy_update = subparsers.add_parser(
"wine-update",
help="legacy official WPF updater (normally unnecessary)",
)
legacy_update.add_argument("--dry-run", action="store_true")
legacy_update.add_argument("--force-runtime", action="store_true")
legacy_import_options = legacy_update.add_mutually_exclusive_group()
legacy_import_options.add_argument("--seed-from", type=Path)
legacy_import_options.add_argument("--no-import", action="store_true")
import_game = subparsers.add_parser(
"import-game",
help="safely seed the managed installation from an existing game",
)
import_game.add_argument("source", type=Path)
import_game.add_argument("--dry-run", action="store_true")
compatibility = subparsers.add_parser(
"cache-compatibility",
help="cache one exact verified Game.exe/game.dll compatibility pair",
)
compatibility.add_argument("source", type=Path)
prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
prepare.add_argument("--game-dir", type=Path)
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
play.add_argument("--game-dir", type=Path)
play.add_argument(
"--realm",
help="launch an available realm from the last official depot manifest",
)
play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
play.add_argument(
"--publisher-dll",
"--no-world-fix",
action="store_true",
dest="publisher_dll",
help=(
"compatibility option retained for older scripts; publisher code is "
"always used without the retired world fix"
),
)
play.add_argument("--dry-run", action="store_true")
play.add_argument("--skip-runtime-setup", action="store_true")
login_trace = subparsers.add_parser(
"login-trace",
help="retired unsafe authentication trace (always refuses to launch)",
)
login_trace.add_argument("--game-dir", type=Path)
login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
login_trace.add_argument("--skip-runtime-setup", action="store_true")
login_trace.add_argument(
"--timeout",
type=float,
default=DEFAULT_TRACE_TIMEOUT_SECONDS,
help="maximum game runtime in seconds (default: 600)",
)
settings = subparsers.add_parser("settings", help="show, initialize, or reset settings")
settings_action = settings.add_mutually_exclusive_group()
settings_action.add_argument(
"--init",
action="store_true",
help="create missing settings and migrate legacy files without resetting choices",
)
settings_action.add_argument(
"--reset",
action="store_true",
help="explicitly replace all launcher settings with defaults",
)
launcher_update = subparsers.add_parser(
"launcher-update",
help="check, verify, install, or roll back the native Linux launcher",
)
launcher_action = launcher_update.add_mutually_exclusive_group()
launcher_action.add_argument(
"--check",
action="store_true",
help="authenticate the update manifest without installing it",
)
launcher_action.add_argument(
"--verify",
action="store_true",
help="download and fully validate the published release without installing it",
)
launcher_action.add_argument(
"--rollback",
nargs="?",
const="",
metavar="VERSION",
help=(
"switch to VERSION from signed release history; without VERSION, "
"switch to the locally retained previous release"
),
)
launcher_update.add_argument(
"--manifest-url",
help="override the signed stable-manifest URL",
)
launcher_update.add_argument(
"--public-key",
type=Path,
help="override the pinned launcher-update public key",
)
launcher_update.add_argument(
"--history-url",
help="override the signed launcher release-history URL",
)
return parser
def main(
argv: Sequence[str] | None = None,
*,
_manage_telemetry: bool | None = None,
) -> int:
parser = build_parser()
arguments = parser.parse_args(argv)
command = arguments.command or "gui"
paths = AppPaths.from_environment()
manage_telemetry = argv is None if _manage_telemetry is None else _manage_telemetry
telemetry_lifecycle = _start_command_telemetry(
sys.modules[__name__],
paths,
command,
arguments,
manage_telemetry,
)
telemetry = telemetry_lifecycle.telemetry
telemetry_events = telemetry_lifecycle.events
operation_started = telemetry_lifecycle.started_at
try:
returncode = _dispatch_command(parser, arguments, command, paths)
except KeyboardInterrupt as exc:
if command == "play":
if (
not isinstance(exc, _GameLaunchInterruptionReported)
and not bool(getattr(arguments, "dry_run", False))
):
_report_game_launch_cancelled(
telemetry,
operation_started,
stage="preflight",
)
else:
_report_command_failure(
telemetry,
telemetry_events,
command,
operation_started,
"Operation interrupted",
stage="interrupted",
)
message = "Interrupted; PristonTale and its compatibility runtime were stopped."
if arguments.json:
_print_json({"ok": False, "error": message, "interrupted": True})
else:
print(f"\n{message}", file=sys.stderr)
return 130
except (ValueError, OSError, RuntimeError) as exc:
_report_command_failure(
telemetry,
telemetry_events,
command,
operation_started,
str(exc),
stage=_failure_stage(command, exc),
)
if telemetry is not None and isinstance(exc, ValueError):
telemetry.send_event(
"configuration_error",
severity="error",
message=str(exc),
metadata={"setting": "unknown", "reason": "invalid_value"},
context={"operation": command, "stage": "configuration"},
)
if arguments.json:
_print_json({"ok": False, "error": str(exc)})
else:
print(f"Error: {exc}", file=sys.stderr)
if isinstance(exc, CommandError):
if exc.outcome.stdout:
print(exc.outcome.stdout.rstrip(), file=sys.stderr)
if exc.outcome.stderr:
print(exc.outcome.stderr.rstrip(), file=sys.stderr)
return 1
except BaseException as exc:
if telemetry is not None:
telemetry.report_exception(
"launcher_exception",
exc,
metadata={"command": command},
context={"operation": command, "stage": "dispatch"},
severity="critical",
)
_report_command_failure(
telemetry,
telemetry_events,
command,
operation_started,
str(exc),
stage="dispatch",
)
raise
else:
_report_command_result(
telemetry,
telemetry_events,
command,
operation_started,
returncode,
)
return returncode
finally:
_shutdown_command_telemetry(sys.modules[__name__], telemetry_lifecycle)
def _dispatch_command(
parser: argparse.ArgumentParser,
arguments: argparse.Namespace,
command: str,
paths: AppPaths,
) -> int:
if command == "gui":
provision_umu_for_startup(paths)
_container_host_startup_migration(paths)
from .ui import run_dashboard
run_dashboard(paths)
return 0
if command == "doctor":
return _doctor(arguments.json)
if command == "container-host":
return _container_host(arguments.repair, arguments.json)
if command == "runtime-bootstrap":
return _runtime_bootstrap(paths, arguments.json)
if command == "login-diagnostics":
return _login_diagnostics(
paths,
arguments.timeout,
arguments.runtime,
arguments.json,
)
if command == "fetch":
return _fetch(paths, arguments.dry_run, arguments.json)
if command == "setup":
return _setup(
paths,
arguments.dry_run,
arguments.force_runtime,
arguments.json,
)
if command == "update":
return _native_update(
paths,
arguments.dry_run,
arguments.repair,
arguments.seed_from,
arguments.no_import,
arguments.json,
)
if command == "sync":
return _native_update(
paths,
arguments.dry_run,
arguments.repair,
None,
True,
arguments.json,
)
if command == "wine-update":
return _wine_update(
paths,
arguments.dry_run,
arguments.force_runtime,
arguments.seed_from,
arguments.no_import,
arguments.json,
)
if command == "import-game":
return _import_game(
paths,
arguments.source,
arguments.dry_run,
arguments.json,
)
if command == "cache-compatibility":
result = cache_compatibility_binaries(paths, arguments.source)
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
return 0
if command == "prepare":
return _prepare(paths, arguments.game_dir, arguments.json)
if command == "play":
return _play(
paths,
arguments.game_dir,
arguments.no_box,
arguments.dry_run,
arguments.skip_runtime_setup,
arguments.json,
realm=arguments.realm,
)
if command == "login-trace":
return _login_trace(
paths,
arguments.game_dir,
arguments.no_box,
arguments.skip_runtime_setup,
arguments.timeout,
arguments.json,
)
if command == "settings":
return _settings(paths, arguments.init, arguments.reset, arguments.json)
if command == "launcher-update":
return _launcher_update(
paths,
arguments.check,
arguments.verify,
arguments.rollback,
arguments.manifest_url,
arguments.public_key,
arguments.json,
history_url=arguments.history_url,
)
parser.error(f"unknown command: {command}")
_command_telemetry_events = _application_helpers.command_telemetry_events
_start_command_telemetry = _application_helpers.start_command_telemetry
_shutdown_command_telemetry = _application_helpers.shutdown_command_telemetry
_report_command_result = _application_helpers.report_command_result
_report_command_failure = _application_helpers.report_command_failure
_report_game_launch_cancelled = _application_helpers.report_game_launch_cancelled
_failure_stage = _application_helpers.failure_stage
def _doctor(as_json: bool) -> int:
return _health_commands.doctor(sys.modules[__name__], as_json)
def _container_host(repair: bool, as_json: bool) -> int:
return _health_commands.container_host(sys.modules[__name__], repair, as_json)
_container_host_ok = _health_commands.container_host_ok
def _container_host_startup_migration(paths: AppPaths) -> None:
_health_commands.container_host_startup_migration(sys.modules[__name__], paths)
def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int:
return _health_commands.runtime_bootstrap(sys.modules[__name__], paths, as_json)
def _login_diagnostics(
paths: AppPaths,
timeout_seconds: float,
runtime_choice: str,
as_json: bool,
) -> int:
return _health_commands.login_diagnostics(
sys.modules[__name__],
paths,
timeout_seconds,
runtime_choice,
as_json,
)
def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int:
return _installation_commands.fetch(
sys.modules[__name__],
paths,
dry_run,
as_json,
)
def _setup(
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
as_json: bool,
) -> int:
return _installation_commands.setup(
sys.modules[__name__],
paths,
dry_run,
force_runtime,
as_json,
)
def _native_update(
paths: AppPaths,
dry_run: bool,
repair: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
return _installation_commands.native_update(
sys.modules[__name__],
paths,
dry_run,
repair,
seed_from,
no_import,
as_json,
)
def _wine_update(
paths: AppPaths,
dry_run: bool,
force_runtime: bool,
seed_from: Path | None,
no_import: bool,
as_json: bool,
) -> int:
return _installation_commands.wine_update(
sys.modules[__name__],
paths,
dry_run,
force_runtime,
seed_from,
no_import,
as_json,
)
def _configure_launcher_runtime(
paths: AppPaths,
tools: WineTools,
runner: CommandRunner,
*,
force_runtime: bool,
) -> list[CommandOutcome]:
return _installation_commands.configure_launcher_runtime(
sys.modules[__name__],
paths,
tools,
runner,
force_runtime=force_runtime,
)
_print_sync_progress = _installation_commands.print_sync_progress
_configured_download_options = _installation_commands.configured_download_options
_print_operation_status = _installation_commands.print_operation_status
def _import_game(
paths: AppPaths,
source: Path,
dry_run: bool,
as_json: bool,
) -> int:
return _installation_commands.import_game(
sys.modules[__name__],
paths,
source,
dry_run,
as_json,
)
def _maybe_import_game(
paths: AppPaths,
settings: Settings,
seed_from: Path | None,
runner: CommandRunner,
*,
announce: bool,
) -> GameImportResult | None:
return _installation_commands.maybe_import_game(
sys.modules[__name__],
paths,
settings,
seed_from,
runner,
announce=announce,
)
def _import_with_telemetry(
paths: AppPaths,
source: Path,
runner: CommandRunner,
) -> GameImportResult:
return _installation_commands.import_with_telemetry(
sys.modules[__name__],
paths,
source,
runner,
)
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
return _installation_commands.prepare(
sys.modules[__name__],
paths,
game_dir,
as_json,
)
def _play(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
) -> int:
return _play_commands.play(
sys.modules[__name__],
paths,
game_dir,
no_box,
dry_run,
skip_runtime_setup,
as_json,
realm=realm,
)
def _play_locked(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
reuse_prepared: bool = False,
before_game: Callable[[], None] | None = None,
game_lease_fds: Sequence[int] = (),
) -> int:
return _play_commands.play_locked(
sys.modules[__name__],
paths,
game_dir,
no_box,
dry_run,
skip_runtime_setup,
as_json,
realm=realm,
reuse_prepared=reuse_prepared,
before_game=before_game,
game_lease_fds=game_lease_fds,
)
_session_duration_class = _play_commands.session_duration_class
_game_exit_metadata = _play_commands.game_exit_metadata
_signal_name = _play_commands.signal_name
def _ensure_portable_umu_for_play(
paths: AppPaths,
settings: Settings,
*,
dry_run: bool,
) -> UmuBootstrapResult | None:
return _play_commands.ensure_portable_umu_for_play(
sys.modules[__name__],
paths,
settings,
dry_run=dry_run,
)
def _current_game_log(
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
*,
managed_asset_paths: Sequence[str] | None = None,
source_game: Path | None = None,
) -> dict[str, object] | None:
return _play_commands.current_game_log(
sys.modules[__name__],
directory,
baseline,
started_ns,
managed_asset_paths=managed_asset_paths,
source_game=source_game,
)
def _current_game_crash(
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
) -> dict[str, object] | None:
return _play_commands.current_game_crash(
sys.modules[__name__],
directory,
baseline,
started_ns,
)
_runtime_issue_summary = _play_commands.runtime_issue_summary
_gamescope_presentation_failure = _play_commands.gamescope_presentation_failure
_game_window_was_ready = _play_commands.game_window_was_ready
_game_readiness_signal = _play_commands.game_readiness_signal
_window_diagnostic_severity = _play_commands.window_diagnostic_severity
_print_game_outcome = _play_commands.print_game_outcome
_print_interrupted_game_log = _play_commands.print_interrupted_game_log
_login_trace = _play_commands.login_trace
def _settings(
paths: AppPaths,
initialize: bool,
reset: bool,
as_json: bool,
) -> int:
return _settings_commands.settings(
sys.modules[__name__],
paths,
initialize,
reset,
as_json,
)
def _launcher_update(
paths: AppPaths,
check_only: bool,
verify: bool,
rollback: str | bool | None,
manifest_url: str | None,
public_key: Path | None,
as_json: bool,
*,
history_url: str | None = None,
) -> int:
return _launcher_update_commands.launcher_update(
sys.modules[__name__],
paths,
check_only,
verify,
rollback,
manifest_url,
public_key,
as_json,
history_url=history_url,
)
def _source_game(
paths: AppPaths,
_settings: Settings,
argument: Path | None,
) -> Path:
"""Select the managed game unless an explicit diagnostic path was supplied.
The persisted ``game_source`` setting is an import seed only. It must not
redirect normal Play, Prepare, or Login Trace operations away from the
launcher-managed tree.
"""
if argument is not None:
return argument.expanduser().resolve()
return paths.source_game
def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
if paths.settings_file.is_file():
return load_settings(paths)
if persist:
return ensure_settings(paths)
return Settings()
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
return CommandRunner(
paths.logs / f"{timestamp}-{operation}.jsonl",
dry_run=dry_run,
operation=operation,
)
def _play_guard(
paths: AppPaths,
dry_run: bool,
) -> AbstractContextManager[LauncherPlayLease]:
if dry_run:
return nullcontext(LauncherPlayLease.dry_run())
return launcher_play_lock(
paths.launcher_operation_lock,
active_game_probe=lambda: _managed_game_process_is_active(paths),
)
def _operation_guard(
paths: AppPaths,
operation: str,
dry_run: bool,
) -> AbstractContextManager[None]:
if dry_run:
return nullcontext()
return launcher_operation_lock(
paths.launcher_operation_lock,
operation,
active_game_probe=lambda: _managed_game_process_is_active(paths),
)
def _managed_game_process_is_active(paths: AppPaths) -> bool:
return managed_game_process_is_active(
paths.runtime_view,
(paths.proton_prefix, paths.umu_prefix),
)
def _emit(value: object, as_json: bool) -> None:
_application_helpers.emit(value, as_json, json_printer=_print_json)
def _print_json(value: object) -> None:
_application_helpers.print_json(value)

View File

@@ -15,6 +15,8 @@ import threading
import time
from typing import Callable, Mapping, Sequence, TextIO
from .telemetry import current_telemetry
OutputLineFilter = Callable[[str], str | None]
MAX_CAPTURE_CHARACTERS = 1024 * 1024
@@ -85,9 +87,16 @@ class CommandError(RuntimeError):
class CommandRunner:
"""Run argument arrays with timeouts and append-only JSONL logging."""
def __init__(self, log_path: Path, *, dry_run: bool = False) -> None:
def __init__(
self,
log_path: Path,
*,
dry_run: bool = False,
operation: str | None = None,
) -> None:
self.log_path = log_path
self.dry_run = dry_run
self.operation = operation
def run(
self,
@@ -99,10 +108,16 @@ class CommandRunner:
check: bool = True,
stream_output: bool = False,
output_line_filter: OutputLineFilter | None = None,
pass_fds: Sequence[int] = (),
telemetry_stage: str | None = None,
report_failure_telemetry: bool = True,
) -> CommandOutcome:
arguments = [str(value) for value in argv]
if not arguments:
raise ValueError("command argv cannot be empty")
inherited_fds = tuple(dict.fromkeys(pass_fds))
if any(not isinstance(value, int) or value < 0 for value in inherited_fds):
raise ValueError("inherited file descriptors must be non-negative integers")
overrides = dict(env_overrides or {})
started = time.monotonic()
if self.dry_run:
@@ -128,6 +143,7 @@ class CommandRunner:
timeout_seconds=timeout_seconds,
started=started,
output_line_filter=output_line_filter,
pass_fds=inherited_fds,
)
else:
try:
@@ -142,6 +158,7 @@ class CommandRunner:
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
pass_fds=inherited_fds,
)
outcome = CommandOutcome(
argv=arguments,
@@ -163,6 +180,20 @@ class CommandRunner:
dry_run=False,
)
self._record(outcome, cwd, overrides)
if report_failure_telemetry and (
outcome.timed_out or outcome.returncode != 0
):
telemetry = current_telemetry()
if telemetry is not None:
telemetry.report_subprocess_failure(
"subprocess_failed",
outcome,
command=outcome.argv[:1],
metadata={
"operation": self.operation or "subprocess",
**({"stage": telemetry_stage} if telemetry_stage else {}),
},
)
if check and (outcome.timed_out or outcome.returncode != 0):
raise CommandError(outcome)
return outcome
@@ -176,6 +207,7 @@ class CommandRunner:
timeout_seconds: float,
started: float,
output_line_filter: OutputLineFilter | None,
pass_fds: tuple[int, ...],
) -> CommandOutcome:
process = subprocess.Popen(
arguments,
@@ -188,6 +220,7 @@ class CommandRunner:
stderr=subprocess.PIPE,
bufsize=1,
start_new_session=True,
pass_fds=pass_fds,
)
stdout = BoundedTextCapture()
stderr = BoundedTextCapture()
@@ -309,15 +342,22 @@ def _finish_stream_relays(
stdout_thread: threading.Thread,
stderr_thread: threading.Thread,
) -> None:
"""Bound relay cleanup so a second interrupt can never be required."""
"""Bound relay cleanup even when an orphan inherits the output pipes."""
stdout_thread.join(timeout=2.0)
stderr_thread.join(timeout=2.0)
for stream in (process.stdout, process.stderr):
if stream is not None:
for stream, relay_thread in (
(process.stdout, stdout_thread),
(process.stderr, stderr_thread),
):
# TextIOWrapper.close() waits for an active reader's internal lock. Wine
# and Gamescope helpers can outlive their direct parent while retaining a
# standard-output descriptor, leaving readline() blocked even though the
# supervised process has exited. The play command itself is short-lived,
# so let its daemon relay leave with the process instead of blocking the
# game-completion event indefinitely.
if stream is not None and not relay_thread.is_alive():
try:
stream.close()
except OSError:
pass
stdout_thread.join(timeout=0.25)
stderr_thread.join(timeout=0.25)

View File

@@ -0,0 +1,889 @@
"""Diagnose and narrowly repair the Bubblewrap host sandbox used by UMU."""
from __future__ import annotations
from dataclasses import asdict, dataclass, replace
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
from typing import Callable, Mapping, Sequence
from . import __version__
from .paths import AppPaths
UID_MAP_PERMISSION_DENIED = "bwrap: setting up uid map: permission denied"
_BWRAP_ATTACHMENT_PATTERN = re.compile(
r"(?m)^[ \t]*(?:profile[ \t]+[^\s{]+[ \t]+)?/usr/bin/bwrap(?=[ \t{])"
)
_MAX_APPARMOR_PROFILE_BYTES = 1024 * 1024
# AppArmor upstream's restrictive Bubblewrap profile, pinned to a commit and
# its AppArmor 4.0 ABI so it works on Ubuntu 24.04. It grants Bubblewrap the
# setup capabilities it needs, then stacks children under unpriv_bwrap, where
# capabilities are explicitly denied. This is intentionally not an unconfined
# profile and does not disable Ubuntu's global user-namespace restriction.
BWRAP_APPARMOR_PROFILE = """# Installed by the PristonTale EU Linux launcher.
# Upstream source:
# https://gitlab.com/apparmor/apparmor/-/blob/8e431ebcd915216a03ebc8d01e72b1741bb2f855/profiles/apparmor/profiles/extras/bwrap-userns-restrict
#
# This profile allows almost everything and only exists to allow bwrap
# to work on a system with user namespace restrictions being enforced.
# bwrap is allowed access to user namespaces and capabilities within
# the user namespace, but its children do not have capabilities,
# blocking bwrap from being able to be used to arbitrarily by-pass the
# user namespace restrictions.
# Note: the bwrap child is stacked against the bwrap profile due to
# bwraps use of no-new-privs.
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(attach_disconnected,mediate_deleted) {
allow capability,
# not allow all, to allow for pix stack on systems that don't support
# rule priority.
#
# sadly we have to allow 'm' every where to allow children to work under
# profile stacking atm.
allow file rwlkm /{**,},
allow network,
allow unix,
allow ptrace,
allow signal,
allow mqueue,
allow io_uring,
allow userns,
allow mount,
allow umount,
allow pivot_root,
allow dbus,
# stacked like this due to no-new-privs restriction
# this will stack a target profile against bwrap and unpriv_bwrap
# Ideally
# - there would be a transition at userns creation first. This would allow
# for the bwrap profile to be tighter, and looser within the user
# ns. bwrap will still have to fairly loose until a transition at
# namespacing in general (not just user ns) is available.
# - there would be an independent second target as fallback
# This would allow for select target profiles to be used, and not
# necessarily stack the unpriv_bwrap in cases where this is desired
#
# the ix works here because stack will apply to ix fallback
# Ideally we would sanitize the environment across a privilege boundry
# (leaving bwarp into application) but flatpak etc use environment glibc
# sanitized environment variables as part of the sandbox setup.
allow pix /** -> &bwrap//&unpriv_bwrap,
# the local include should not be used without understanding the userns
# restriction.
# Site-specific additions and overrides. See local/README for details.
include if exists <local/bwrap-userns-restrict>
}
# The unpriv_bwrap profile is used to strip capabilities within the userns
profile unpriv_bwrap flags=(attach_disconnected,mediate_deleted) {
# not allow all, to allow for pix stack
allow file rwlkm /{**,},
allow network,
allow unix,
allow ptrace,
allow signal,
allow mqueue,
allow io_uring,
allow userns,
allow mount,
allow umount,
allow pivot_root,
allow dbus,
# bwrap profile does stacking against itself this will keep the target
# profile from having elevated privileges in the container.
# If done recursively the stack will remove any duplicate
allow pix /** -> &unpriv_bwrap,
audit deny capability,
# the local include should not be used without understanding the userns
# restriction.
# Site-specific additions and overrides. See local/README for details.
include if exists <local/unpriv_bwrap>
}
"""
BWRAP_APPARMOR_PROFILE_SHA256 = hashlib.sha256(
BWRAP_APPARMOR_PROFILE.encode("utf-8")
).hexdigest()
@dataclass(frozen=True)
class ContainerHostFiles:
"""Fixed host paths, injectable so repair logic can be tested safely."""
bwrap: Path = Path("/usr/bin/bwrap")
probe_program: Path = Path("/usr/bin/true")
os_release: Path = Path("/etc/os-release")
apparmor_parser: Path = Path("/usr/sbin/apparmor_parser")
apparmor_directory: Path = Path("/etc/apparmor.d")
install: Path = Path("/usr/bin/install")
link: Path = Path("/usr/bin/ln")
remove: Path = Path("/usr/bin/rm")
obsolete_umu_profile_target: Path = Path(
"/usr/share/apparmor/extra-profiles/bwrap-userns-restrict"
)
apparmor_restrict_userns: Path = Path(
"/proc/sys/kernel/apparmor_restrict_unprivileged_userns"
)
unprivileged_userns_clone: Path = Path(
"/proc/sys/kernel/unprivileged_userns_clone"
)
max_user_namespaces: Path = Path("/proc/sys/user/max_user_namespaces")
@property
def distribution_profile(self) -> Path:
return self.apparmor_directory / "bwrap-userns-restrict"
@property
def managed_profile(self) -> Path:
return self.apparmor_directory / "bwrap-userns-restrict-ptlinux"
@property
def umu_profile_link(self) -> Path:
return self.apparmor_directory / "bwrap-userns-restrict-umu"
DEFAULT_CONTAINER_HOST_FILES = ContainerHostFiles()
CommandExecutor = Callable[[Sequence[str], float], subprocess.CompletedProcess[str]]
@dataclass(frozen=True)
class ContainerHostReport:
ready: bool
issue: str | None
distribution: str
bwrap_path: str | None
probe_returncode: int | None
probe_stderr: str
apparmor_restrict_unprivileged_userns: str | None
unprivileged_userns_clone: str | None
max_user_namespaces: str | None
active_profile_source: str | None
conflicting_bwrap_profile: str | None
broken_umu_profile_link: bool
repairable: bool
recommendation: str | None
repair_attempted: bool = False
repaired: bool = False
repair_actions: tuple[str, ...] = ()
repair_error: str | None = None
def to_dict(self) -> dict[str, object]:
return asdict(self)
def inspect_container_host(
*,
environment: Mapping[str, str] | None = None,
files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES,
command_executor: CommandExecutor | None = None,
timeout_seconds: float = 5.0,
) -> ContainerHostReport:
"""Run a harmless user-namespace probe and classify the exact failure."""
_ = environment
distribution_data = _read_os_release(files.os_release)
distribution = distribution_data.get("ID", "unknown")
bwrap_available = files.bwrap.is_file() and os.access(files.bwrap, os.X_OK)
restrict_userns = _read_setting(files.apparmor_restrict_userns)
userns_clone = _read_setting(files.unprivileged_userns_clone)
max_user_namespaces = _read_setting(files.max_user_namespaces)
profile = _active_profile_source(files)
broken_link = _known_broken_umu_profile_link(files)
returncode: int | None = None
stderr = ""
issue: str | None = None
if not bwrap_available:
issue = "bubblewrap-missing"
else:
command = (
str(files.bwrap),
"--ro-bind",
"/",
"/",
"--unshare-user",
"--",
str(files.probe_program),
)
try:
result = _run(command, timeout_seconds, command_executor)
returncode = result.returncode
stderr = _bounded_output(result.stderr)
except subprocess.TimeoutExpired as exc:
issue = "bubblewrap-probe-timeout"
stderr = _bounded_output(_timeout_stderr(exc))
except OSError as exc:
issue = "bubblewrap-probe-error"
stderr = str(exc)
if issue is None and returncode != 0:
folded = stderr.casefold()
if UID_MAP_PERMISSION_DENIED in folded:
issue = "uid-map-permission-denied"
elif userns_clone == "0":
issue = "unprivileged-userns-disabled"
elif max_user_namespaces == "0":
issue = "user-namespace-limit-zero"
else:
issue = "bubblewrap-probe-failed"
conflicting_profile = (
_find_conflicting_bwrap_profile(files)
if issue == "uid-map-permission-denied" or broken_link
else None
)
ubuntu_family = _is_ubuntu_family(distribution_data)
managed_collision = _managed_profile_collision(files)
repairable = (
ubuntu_family
and restrict_userns == "1"
and files.apparmor_parser.is_file()
and os.access(files.apparmor_parser, os.X_OK)
and bwrap_available
and not managed_collision
and conflicting_profile is None
and (issue == "uid-map-permission-denied" or broken_link)
)
recommendation = _recommendation(
issue,
repairable=repairable,
broken_link=broken_link,
conflicting_profile=conflicting_profile,
stderr=stderr,
)
return ContainerHostReport(
ready=issue is None,
issue=issue,
distribution=distribution,
bwrap_path=str(files.bwrap) if bwrap_available else None,
probe_returncode=returncode,
probe_stderr=stderr,
apparmor_restrict_unprivileged_userns=restrict_userns,
unprivileged_userns_clone=userns_clone,
max_user_namespaces=max_user_namespaces,
active_profile_source=str(profile) if profile else None,
conflicting_bwrap_profile=(
str(conflicting_profile) if conflicting_profile else None
),
broken_umu_profile_link=broken_link,
repairable=repairable,
recommendation=recommendation,
)
def repair_container_host(
*,
environment: Mapping[str, str] | None = None,
files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES,
command_executor: CommandExecutor | None = None,
privileged_executor: CommandExecutor | None = None,
) -> ContainerHostReport:
"""Repair only known Ubuntu AppArmor states, then repeat the probe."""
before = inspect_container_host(
environment=environment,
files=files,
command_executor=command_executor,
)
started_at = time.monotonic()
_send_dependency_fix_event(
"dependency_fix_requested",
metadata={"reason": "container_host", "issue": before.issue},
stage="requested",
)
_send_dependency_fix_event(
"dependency_fix_started",
metadata={"issue": before.issue, "repairable": before.repairable},
stage="started",
)
if before.ready and not before.broken_umu_profile_link:
_send_dependency_fix_outcome(before, started_at)
return before
if not before.repairable:
result = replace(
before,
repair_attempted=True,
repair_error=(
before.recommendation or "No safe automatic repair is available."
),
)
_send_dependency_fix_outcome(result, started_at)
return result
actions: list[str] = []
created_managed_profile = False
profile_loaded = False
selected_profile = _active_profile_source(files)
try:
if selected_profile is None:
selected_profile = files.managed_profile
_validate_and_install_managed_profile(
files,
command_executor=command_executor,
privileged_executor=privileged_executor,
)
created_managed_profile = True
actions.append(f"installed {selected_profile}")
else:
_validate_profile(
selected_profile,
files,
command_executor=command_executor,
)
loaded = _run_privileged(
(str(files.apparmor_parser), "-r", str(selected_profile)),
120.0,
privileged_executor,
)
_require_success(loaded, f"could not load AppArmor profile {selected_profile}")
profile_loaded = True
actions.append(f"loaded {selected_profile}")
if _known_broken_umu_profile_link(files):
linked = _run_privileged(
(
str(files.link),
"-sfn",
"--",
str(selected_profile),
str(files.umu_profile_link),
),
120.0,
privileged_executor,
)
_require_success(
linked,
"could not repair UMU's broken AppArmor profile link",
)
actions.append(f"repaired {files.umu_profile_link}")
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
rollback_error = None
if created_managed_profile and not profile_loaded:
rollback_error = _rollback_managed_profile(files, privileged_executor)
if rollback_error is None:
actions.append(f"rolled back {files.managed_profile}")
after_failure = inspect_container_host(
environment=environment,
files=files,
command_executor=command_executor,
)
error = str(exc)
if rollback_error:
error = f"{error}; rollback also failed: {rollback_error}"
result = replace(
after_failure,
repair_attempted=True,
repair_actions=tuple(actions),
repair_error=error,
)
_send_dependency_fix_outcome(result, started_at)
return result
after = inspect_container_host(
environment=environment,
files=files,
command_executor=command_executor,
)
error = None
if not after.ready:
error = (
"The AppArmor profile loaded, but the Bubblewrap user-namespace probe "
"still fails. "
+ (after.recommendation or "Inspect the host security policy.")
)
result = replace(
after,
repair_attempted=True,
repaired=after.ready,
repair_actions=tuple(actions),
repair_error=error,
)
_send_dependency_fix_outcome(result, started_at)
return result
def _send_dependency_fix_outcome(
report: ContainerHostReport,
started_at: float,
) -> None:
success = report.ready and not report.broken_umu_profile_link and (
report.repair_error is None
)
_send_dependency_fix_event(
"dependency_fix_success" if success else "dependency_fix_failed",
severity="info" if success else "error",
message=None if success else report.repair_error,
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
"ready": report.ready,
"repaired": report.repaired,
"repair_attempted": report.repair_attempted,
"action_count": len(report.repair_actions),
"issue": report.issue,
},
stage="completed" if success else "repair",
)
def _send_dependency_fix_event(
event: str,
*,
severity: str = "info",
message: str | None = None,
metadata: Mapping[str, object] | None = None,
stage: str,
) -> None:
try:
from .telemetry import current_telemetry
telemetry = current_telemetry()
if telemetry is not None:
telemetry.send_event(
event,
severity=severity,
message=message,
metadata=metadata,
context={"operation": "dependency_fix", "stage": stage},
)
except Exception:
# Host recovery must remain independent from optional telemetry.
pass
def repair_container_host_after_update(paths: AppPaths) -> ContainerHostReport:
"""Run the repair once after activation, including upgrades by older code."""
status = inspect_container_host()
if status.ready and not status.broken_umu_profile_link:
return status
if not _launcher_was_updated_to_current_version(paths):
return status
marker = paths.container_host_repair_state
if _repair_was_attempted(marker, status):
return status
print(
"Launcher update: checking Steam Runtime sandbox permissions; "
"authentication may be requested for a narrow AppArmor repair.",
file=sys.stderr,
)
report = repair_container_host()
try:
_write_repair_marker(marker, report)
except OSError as exc:
print(
f"Warning: could not record the container-host repair attempt: {exc}",
file=sys.stderr,
)
return report
def container_host_error(report: ContainerHostReport) -> str:
if report.ready and report.broken_umu_profile_link:
return report.repair_error or report.recommendation or (
"UMU's AppArmor profile link remains broken."
)
message = (
"The Steam Runtime container cannot create its Bubblewrap user namespace."
)
if report.repair_error:
return f"{message} Host repair failed: {report.repair_error}"
if report.recommendation:
return f"{message} {report.recommendation}"
if report.probe_stderr:
return f"{message} Bubblewrap reported: {report.probe_stderr}"
return message
def _validate_and_install_managed_profile(
files: ContainerHostFiles,
*,
command_executor: CommandExecutor | None,
privileged_executor: CommandExecutor | None,
) -> None:
if files.managed_profile.exists() or files.managed_profile.is_symlink():
if _is_managed_profile(files.managed_profile):
_validate_profile(
files.managed_profile,
files,
command_executor=command_executor,
)
return
raise RuntimeError(
f"refusing to overwrite an unmanaged AppArmor file: {files.managed_profile}"
)
with tempfile.TemporaryDirectory(prefix="ptlinux-bwrap-profile-") as temporary:
candidate = Path(temporary) / "bwrap-userns-restrict-ptlinux"
candidate.write_text(BWRAP_APPARMOR_PROFILE, encoding="utf-8")
candidate.chmod(0o600)
_validate_profile(candidate, files, command_executor=command_executor)
installed = _run_privileged(
(
str(files.install),
"-m",
"0644",
"--",
str(candidate),
str(files.managed_profile),
),
120.0,
privileged_executor,
)
_require_success(installed, "could not install the managed AppArmor profile")
def _validate_profile(
profile: Path,
files: ContainerHostFiles,
*,
command_executor: CommandExecutor | None,
) -> None:
validated = _run(
(str(files.apparmor_parser), "-Q", "-K", str(profile)),
30.0,
command_executor,
)
_require_success(validated, f"AppArmor rejected profile {profile}")
def _rollback_managed_profile(
files: ContainerHostFiles,
privileged_executor: CommandExecutor | None,
) -> str | None:
try:
removed = _run_privileged(
(str(files.remove), "--", str(files.managed_profile)),
120.0,
privileged_executor,
)
_require_success(removed, "could not remove the newly installed profile")
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
return str(exc)
return None
def _run(
command: Sequence[str],
timeout_seconds: float,
executor: CommandExecutor | None,
) -> subprocess.CompletedProcess[str]:
if executor is not None:
return executor(tuple(command), timeout_seconds)
return subprocess.run(
list(command),
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
def _run_privileged(
command: Sequence[str],
timeout_seconds: float,
executor: CommandExecutor | None,
) -> subprocess.CompletedProcess[str]:
if executor is not None:
return executor(tuple(command), timeout_seconds)
prefix = _privilege_prefix()
return subprocess.run(
[*prefix, *command],
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
def _privilege_prefix() -> tuple[str, ...]:
if os.geteuid() == 0:
return ()
if sys.stdin.isatty():
for candidate in (Path("/usr/bin/sudo"), Path("/usr/bin/doas")):
if candidate.is_file() and os.access(candidate, os.X_OK):
return (str(candidate),)
pkexec = Path("/usr/bin/pkexec")
if pkexec.is_file() and os.access(pkexec, os.X_OK):
return (str(pkexec),)
sudo = Path("/usr/bin/sudo")
if sudo.is_file() and os.access(sudo, os.X_OK):
return str(sudo), "-n"
raise RuntimeError(
"root access is required for the AppArmor repair, but no usable "
"sudo, doas, or pkexec command was found"
)
def _require_success(result: subprocess.CompletedProcess[str], message: str) -> None:
if result.returncode == 0:
return
detail = _bounded_output(result.stderr) or _bounded_output(result.stdout)
if detail:
raise RuntimeError(f"{message}: {detail}")
raise RuntimeError(f"{message} (exit status {result.returncode})")
def _active_profile_source(files: ContainerHostFiles) -> Path | None:
if files.distribution_profile.is_file():
return files.distribution_profile
if _trusted_umu_profile_link(files):
return files.umu_profile_link
if _is_managed_profile(files.managed_profile):
return files.managed_profile
return None
def _is_managed_profile(path: Path) -> bool:
if not path.is_file() or path.is_symlink():
return False
try:
content = path.read_bytes()
except OSError:
return False
return hashlib.sha256(content).hexdigest() == BWRAP_APPARMOR_PROFILE_SHA256
def _managed_profile_collision(files: ContainerHostFiles) -> bool:
path = files.managed_profile
return (path.exists() or path.is_symlink()) and not _is_managed_profile(path)
def _find_conflicting_bwrap_profile(files: ContainerHostFiles) -> Path | None:
known_names = {
files.distribution_profile.name,
files.managed_profile.name,
}
if _trusted_umu_profile_link(files):
known_names.add(files.umu_profile_link.name)
try:
candidates = sorted(
files.apparmor_directory.iterdir(),
key=lambda path: path.name,
)
except OSError:
return None
for candidate in candidates:
if candidate.name in known_names or not candidate.is_file():
continue
try:
if candidate.stat().st_size > _MAX_APPARMOR_PROFILE_BYTES:
continue
content = candidate.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
policy = "\n".join(line.split("#", 1)[0] for line in content.splitlines())
if _BWRAP_ATTACHMENT_PATTERN.search(policy):
return candidate
return None
def _trusted_umu_profile_link(files: ContainerHostFiles) -> bool:
link = files.umu_profile_link
if not link.is_symlink() or not link.is_file():
return False
try:
target_text = os.readlink(link)
target = Path(target_text)
if not target.is_absolute():
target = link.parent / target
target = target.resolve()
allowed = {
files.distribution_profile.resolve(),
files.managed_profile.resolve(),
files.obsolete_umu_profile_target.resolve(),
}
except OSError:
return False
return target in allowed
def _known_broken_umu_profile_link(files: ContainerHostFiles) -> bool:
link = files.umu_profile_link
if not link.is_symlink():
return False
try:
target_text = os.readlink(link)
except OSError:
return False
if target_text != str(files.obsolete_umu_profile_target):
return False
return not files.obsolete_umu_profile_target.exists()
def _read_os_release(path: Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
result: dict[str, str] = {}
for line in lines:
if "=" not in line or line.startswith("#"):
continue
key, value = line.split("=", 1)
result[key] = value.strip().strip('"').strip("'")
return result
def _is_ubuntu_family(os_release: Mapping[str, str]) -> bool:
distribution = os_release.get("ID", "").casefold()
like = set(os_release.get("ID_LIKE", "").casefold().split())
return distribution == "ubuntu" or "ubuntu" in like
def _read_setting(path: Path) -> str | None:
try:
return path.read_text(encoding="ascii").strip()[:64]
except (OSError, UnicodeError):
return None
def _recommendation(
issue: str | None,
*,
repairable: bool,
broken_link: bool,
conflicting_profile: Path | None,
stderr: str,
) -> str | None:
if issue is None:
if broken_link:
return (
"UMU's AppArmor profile link is broken; run "
"`pristontale-eu container-host --repair` before rebooting."
)
return None
if issue == "bubblewrap-missing":
return (
"Install the distribution's bubblewrap package and run the check again."
)
if issue == "uid-map-permission-denied":
if repairable:
return (
"Ubuntu AppArmor denied Bubblewrap's UID map. Run "
"`pristontale-eu container-host --repair`; authentication is "
"required."
)
if conflicting_profile is not None:
return (
"Bubblewrap's UID map was denied, but an unrelated AppArmor profile "
f"already attaches to /usr/bin/bwrap: {conflicting_profile}. The "
"launcher will not install a competing profile automatically."
)
return (
"Bubblewrap's UID map was denied, but this host does not match the "
"narrow Ubuntu AppArmor repair. Inspect the loaded bwrap policy."
)
if issue == "unprivileged-userns-disabled":
return (
"kernel.unprivileged_userns_clone is disabled. The launcher will not "
"weaken this global security setting automatically."
)
if issue == "user-namespace-limit-zero":
return (
"user.max_user_namespaces is zero. The launcher will not change this "
"global limit automatically."
)
if issue == "bubblewrap-probe-timeout":
return (
"The Bubblewrap sandbox probe timed out; inspect the host sandbox policy."
)
if stderr:
return f"Bubblewrap's sandbox probe failed: {stderr}"
return "Bubblewrap's sandbox probe failed without a diagnostic message."
def _timeout_stderr(error: subprocess.TimeoutExpired) -> str:
value = error.stderr
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value or ""
def _bounded_output(value: str | None, limit: int = 2048) -> str:
if not value:
return ""
cleaned = value.strip()
if len(cleaned) <= limit:
return cleaned
return cleaned[:limit] + "…"
def _launcher_was_updated_to_current_version(paths: AppPaths) -> bool:
try:
data = paths.launcher_update_state.read_bytes()
if len(data) > 64 * 1024:
return False
payload = json.loads(data)
except (OSError, UnicodeError, json.JSONDecodeError):
return False
return isinstance(payload, dict) and payload.get("to_version") == __version__
def _repair_was_attempted(path: Path, status: ContainerHostReport) -> bool:
try:
data = path.read_bytes()
if len(data) > 64 * 1024:
return False
payload = json.loads(data)
except (OSError, UnicodeError, json.JSONDecodeError):
return False
return (
isinstance(payload, dict)
and payload.get("schema") == 1
and payload.get("launcher_version") == __version__
and payload.get("profile_sha256") == BWRAP_APPARMOR_PROFILE_SHA256
and payload.get("issue") == status.issue
and payload.get("broken_umu_profile_link") == status.broken_umu_profile_link
)
def _write_repair_marker(path: Path, report: ContainerHostReport) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema": 1,
"attempted_at": datetime.now().astimezone().isoformat(),
"launcher_version": __version__,
"profile_sha256": BWRAP_APPARMOR_PROFILE_SHA256,
"issue": report.issue,
"broken_umu_profile_link": report.broken_umu_profile_link,
"ready": report.ready,
"repair_error": report.repair_error,
}
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
delete=False,
) as handle:
temporary = Path(handle.name)
try:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
except BaseException:
temporary.unlink(missing_ok=True)
raise
try:
temporary.chmod(0o600)
temporary.replace(path)
except BaseException:
temporary.unlink(missing_ok=True)
raise

View File

@@ -0,0 +1,217 @@
"""Read-only state snapshots for the launcher's hidden debug view."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import json
from pathlib import Path
from typing import Callable
from .import_state import game_import_requires_verification
from .paths import AppPaths
from .runtime import (
RuntimeErrorPT,
RuntimeSelection,
resolve_proton_sync_mode,
select_runtime,
)
from .settings import Settings
@dataclass(frozen=True)
class RuntimeDebugInfo:
"""Configured, selected, and last-prepared runtime state."""
requested_kind: str
selected_kind: str | None
executable: str | None
proton_path: str | None
prefix: str | None
sync_configured: str
sync_effective: str | None
prepared_kind: str | None
prepared_proton_path: str | None
prepared_at: str | None
marker_matches_selection: bool | None
dxvk_d3d9_override: str | None
error: str | None
@dataclass(frozen=True)
class VerificationDebugInfo:
"""Cached trust evidence from completed depot and import operations."""
import_state: str
import_verified_at: str | None
import_build: int | None
last_sync_at: str | None
last_full_verification_at: str | None
build: int | None
version: str | None
mode: str | None
files_total: int | None
files_downloaded: int | None
files_deleted: int | None
files_created: int | None
files_quarantined: int | None
RuntimeSelector = Callable[[Settings], RuntimeSelection]
SyncResolver = Callable[[str], str]
def inspect_runtime_debug(
paths: AppPaths,
settings: Settings,
*,
selector: RuntimeSelector | None = None,
sync_resolver: SyncResolver = resolve_proton_sync_mode,
) -> RuntimeDebugInfo:
"""Resolve the next runtime and compare it with the preparation marker."""
marker = _read_json_object(paths.game_runtime_marker)
prepared_runtime: dict[str, object] | None = None
if marker is not None:
raw_prepared_runtime = marker.get("runtime")
if isinstance(raw_prepared_runtime, dict):
prepared_runtime = raw_prepared_runtime
prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None
prepared_proton_path = (
_text(prepared_runtime.get("proton_path")) if prepared_runtime else None
)
prepared_at = _text(marker.get("configured_at")) if marker else None
dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None
selection: RuntimeSelection | None = None
error: str | None = None
try:
selection = (
select_runtime(settings, paths=paths)
if selector is None
else selector(settings)
)
except (OSError, RuntimeErrorPT, ValueError) as exc:
error = str(exc).strip() or type(exc).__name__
selected_kind = selection.kind if selection is not None else None
prefix_kind = selected_kind or prepared_kind
prefix = None
if prefix_kind == "umu":
prefix = str(paths.umu_prefix)
elif prefix_kind in {"proton7", "proton7-container"}:
prefix = str(paths.proton_prefix)
sync_effective = None
if prefix_kind in {"proton7", "proton7-container"}:
try:
sync_effective = sync_resolver(settings.proton_sync)
except ValueError as exc:
error = error or (str(exc).strip() or type(exc).__name__)
marker_matches = (
prepared_runtime == asdict(selection)
if prepared_runtime is not None and selection is not None
else None
)
return RuntimeDebugInfo(
requested_kind=settings.runtime,
selected_kind=selected_kind,
executable=selection.executable if selection is not None else None,
proton_path=selection.proton_path if selection is not None else None,
prefix=prefix,
sync_configured=settings.proton_sync,
sync_effective=sync_effective,
prepared_kind=prepared_kind,
prepared_proton_path=prepared_proton_path,
prepared_at=prepared_at,
marker_matches_selection=marker_matches,
dxvk_d3d9_override=dxvk_override,
error=error,
)
def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo:
"""Read cached verification state without hashing or changing game files."""
depot = _read_json_object(paths.native_depot_state)
if depot is not None and depot.get("dry_run") is not False:
depot = None
import_manifest_exists = paths.game_import_manifest.exists()
import_manifest = _read_json_object(paths.game_import_manifest)
if not import_manifest_exists:
import_state = "not applicable"
elif game_import_requires_verification(paths):
import_state = "pending"
else:
import_state = "verified"
import_verified_at = (
_text(import_manifest.get("verification_completed_at"))
if import_state == "verified" and import_manifest is not None
else None
)
import_build = (
_non_negative_int(import_manifest.get("verification_build"))
if import_state == "verified" and import_manifest is not None
else None
)
last_sync_at = _text(depot.get("completed_at")) if depot else None
full_verifications = [
value
for value in (
last_sync_at if depot is not None and depot.get("repair") is True else None,
import_verified_at,
)
if value is not None
]
last_full_verification_at = (
max(full_verifications, key=_iso_timestamp)
if full_verifications
else None
)
return VerificationDebugInfo(
import_state=import_state,
import_verified_at=import_verified_at,
import_build=import_build,
last_sync_at=last_sync_at,
last_full_verification_at=last_full_verification_at,
build=_non_negative_int(depot.get("build")) if depot else None,
version=_text(depot.get("version")) if depot else None,
mode=(
"full repair"
if depot is not None and depot.get("repair") is True
else "update"
if depot is not None and depot.get("repair") is False
else None
),
files_total=_non_negative_int(depot.get("files_total")) if depot else None,
files_downloaded=(
_non_negative_int(depot.get("files_downloaded")) if depot else None
),
files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None,
files_created=_non_negative_int(depot.get("files_created")) if depot else None,
files_quarantined=(
_non_negative_int(depot.get("files_quarantined")) if depot else None
),
)
def _read_json_object(path: Path) -> dict[str, object] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _text(value: object) -> str | None:
return value.strip() if isinstance(value, str) and value.strip() else None
def _non_negative_int(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value
def _iso_timestamp(value: str) -> float:
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except (OverflowError, ValueError):
return float("-inf")

Some files were not shown because too many files have changed in this diff Show More