Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be7891f066 | |||
| 6a508476b9 | |||
| 9ef0eff2f5 | |||
| 06469a0ad3 | |||
| f7ceba2b9b | |||
| 67d89b40d5 | |||
| c278efa339 | |||
| f3059c334e | |||
| 2cbb9315bf | |||
| be7a4782e8 | |||
| 15741f5f8a | |||
| 0a89323533 | |||
| cc48bf45d2 | |||
| 84f8de543b | |||
| 1329485ec5 | |||
| 8f1655b241 | |||
| be71d31c48 | |||
| 46e11ae8d5 | |||
| eeb993efc9 | |||
| a90bd19858 | |||
| 9644de3ff5 | |||
| a362b0bb8e | |||
| 27ed039f32 | |||
| 290d1a5383 | |||
| 6e6219107e | |||
| 5770969629 | |||
| 2a53a59cef | |||
| 76bef94757 | |||
| 1a120bbf86 | |||
| 9b0abf6223 | |||
| 108ea94fb7 | |||
| 1cfe8625e9 | |||
| f06fc2e4e3 | |||
| a1ff4794ae | |||
| 6eea3b2f2b | |||
| ee9bb064ff | |||
| bea9d6fda7 | |||
| f53f4d0b08 | |||
| 3cbe4f72b0 | |||
| 6eab4b961f | |||
| 0963ae34b9 | |||
| f9a6d6aed0 | |||
| 3a01c6ba2a | |||
| f0d631be66 | |||
| cccce05a99 | |||
| 09c8f165be | |||
| 8fe627b6cf | |||
| 598d761af4 | |||
| 5be4ace6c2 | |||
| ecdea827ac | |||
| ea323e3ab0 | |||
| c61254e4a9 | |||
| ba079ef6fa | |||
| bb90efa9d1 | |||
| 7ce6ac3dd7 | |||
| 6f20b5e359 | |||
| 55b2292bfe | |||
| 15dbd90dbb | |||
| c2400db19e | |||
| 1a17c1a197 | |||
| 25f490c8b8 | |||
| e8d77f3779 | |||
| 6900c20734 | |||
| f96aba9d99 | |||
| 78dade0d86 | |||
| d819809197 | |||
| dadae8f60a | |||
| 99982b12d9 | |||
| 75b21acf87 | |||
| b16540099d | |||
| b0855ccd9b | |||
| 87dee93b86 | |||
| fe883047e0 | |||
| 20a0bbcd75 | |||
| ed0c278a8b |
75
.gitea/workflows/release-status.yml
Normal file
75
.gitea/workflows/release-status.yml
Normal 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
|
||||
164
.gitea/workflows/release.yml
Normal file
164
.gitea/workflows/release.yml
Normal 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
33
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PristonTale EU for Linux
|
||||
|
||||
Version 0.6.7 installs, updates, repairs, and launches PristonTale EU through a
|
||||
Version 1.0.8 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-v1-0-8.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v1-0-8.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v1-0-8.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v1-0-8.tar.gz
|
||||
cd pristontale-eu-linux-installer-v1-0-8
|
||||
./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.
|
||||
|
||||
176
install.sh
176
install.sh
@@ -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
31
packaging/install-dependencies.sh
Executable file → Normal 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" ]]
|
||||
|
||||
@@ -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}" ]
|
||||
|
||||
1812
ptdiag/experiment.py
1812
ptdiag/experiment.py
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
raise SystemExit(main())
|
||||
1098
ptlinux/cli.py
1098
ptlinux/cli.py
File diff suppressed because it is too large
Load Diff
1455
ptlinux/depot.py
1455
ptlinux/depot.py
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
54
ptlinux/native/gamescope-compat.README
Normal file
54
ptlinux/native/gamescope-compat.README
Normal 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.
|
||||
343
ptlinux/native/gamescope-compat.copyright
Normal file
343
ptlinux/native/gamescope-compat.copyright
Normal 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'.
|
||||
34
ptlinux/native/gamescope-keep-nested-windows-visible.patch
Normal file
34
ptlinux/native/gamescope-keep-nested-windows-visible.patch
Normal 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 {
|
||||
80
ptlinux/native/gamescope-lazy-output-images.patch
Normal file
80
ptlinux/native/gamescope-lazy-output-images.patch
Normal 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;
|
||||
21
ptlinux/native/gamescope-wayland-letterbox-backing.patch
Normal file
21
ptlinux/native/gamescope-wayland-letterbox-backing.patch
Normal 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;
|
||||
}
|
||||
|
||||
64
ptlinux/native/gamescope-wayland-libdecor-input-scope.patch
Normal file
64
ptlinux/native/gamescope-wayland-libdecor-input-scope.patch
Normal 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 )
|
||||
{
|
||||
50
ptlinux/native/gamescope-wayland-live-resize.patch
Normal file
50
ptlinux/native/gamescope-wayland-live-resize.patch
Normal 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
3296
ptlinux/ui.py
3296
ptlinux/ui.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "PTEU-Linux-Launcher"
|
||||
version = "0.6.7"
|
||||
version = "1.0.8"
|
||||
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
216
release/README.md
Normal 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.
|
||||
35
release/SIGNING_MIGRATION.md
Normal file
35
release/SIGNING_MIGRATION.md
Normal 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
140
release/config.json
Normal 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
4
release/statuses.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"releases": {},
|
||||
"schema": 1
|
||||
}
|
||||
1
release_tests/__init__.py
Normal file
1
release_tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tracked preflight tests for the automated release system."""
|
||||
96
release_tests/test_channel_discovery.py
Normal file
96
release_tests/test_channel_discovery.py
Normal 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()
|
||||
102
release_tests/test_channel_metadata.py
Normal file
102
release_tests/test_channel_metadata.py
Normal 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()
|
||||
42
release_tests/test_channel_settings.py
Normal file
42
release_tests/test_channel_settings.py
Normal 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()
|
||||
643
release_tests/test_gitea_publisher.py
Normal file
643
release_tests/test_gitea_publisher.py
Normal 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()
|
||||
38
release_tests/test_launcher_update_cli.py
Normal file
38
release_tests/test_launcher_update_cli.py
Normal 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()
|
||||
270
release_tests/test_release_artifacts.py
Normal file
270
release_tests/test_release_artifacts.py
Normal 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()
|
||||
318
release_tests/test_release_cli.py
Normal file
318
release_tests/test_release_cli.py
Normal 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()
|
||||
156
release_tests/test_release_history.py
Normal file
156
release_tests/test_release_history.py
Normal 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()
|
||||
53
release_tests/test_release_remote.py
Normal file
53
release_tests/test_release_remote.py
Normal 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()
|
||||
603
release_tests/test_remote_rollback.py
Normal file
603
release_tests/test_remote_rollback.py
Normal 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()
|
||||
65
release_tests/test_signing.py
Normal file
65
release_tests/test_signing.py
Normal 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()
|
||||
71
release_tests/test_update_dialog.py
Normal file
71
release_tests/test_update_dialog.py
Normal 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()
|
||||
172
release_tests/test_update_resolver.py
Normal file
172
release_tests/test_update_resolver.py
Normal 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()
|
||||
259
release_tests/test_update_telemetry.py
Normal file
259
release_tests/test_update_telemetry.py
Normal 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
1
scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Release automation modules."""
|
||||
60
scripts/build-release.sh
Executable file
60
scripts/build-release.sh
Executable 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[@]}"
|
||||
66
scripts/build_release.py
Executable file
66
scripts/build_release.py
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import ensure_project_imports
|
||||
else:
|
||||
from repository_paths import ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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())
|
||||
77
scripts/generate-manifest.py
Executable file
77
scripts/generate-manifest.py
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import ensure_project_imports
|
||||
else:
|
||||
from repository_paths import ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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())
|
||||
263
scripts/generate_history.py
Executable file
263
scripts/generate_history.py
Executable file
@@ -0,0 +1,263 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import PROJECT_ROOT, ensure_project_imports
|
||||
else:
|
||||
from repository_paths import PROJECT_ROOT, ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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())
|
||||
165
scripts/prepare_channel_metadata.py
Normal file
165
scripts/prepare_channel_metadata.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import ensure_project_imports
|
||||
else:
|
||||
from repository_paths import ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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
351
scripts/publish-gitea-release.sh
Executable 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}/../src/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}"
|
||||
280
scripts/publish-release-history.sh
Executable file
280
scripts/publish-release-history.sh
Executable 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'
|
||||
381
scripts/release.sh
Executable file
381
scripts/release.sh
Executable file
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage()
|
||||
{
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
|
||||
|
||||
Build a verified local release package in .artifacts/releases/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 an external private signing key at PATH
|
||||
--output-directory PATH Write the version package to PATH
|
||||
-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:-}"
|
||||
REQUESTED_OUTPUT_DIRECTORY=""
|
||||
|
||||
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
|
||||
;;
|
||||
--output-directory)
|
||||
[[ $# -ge 2 ]] || { printf '%s\n' '--output-directory requires a path.' >&2; exit 2; }
|
||||
REQUESTED_OUTPUT_DIRECTORY="$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"
|
||||
export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
"$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 [[ "$DRY_RUN" -eq 1 && "$CHANGELOG_ONLY" -eq 0 ]]
|
||||
then
|
||||
OUTPUT_CANDIDATE="${TEMPORARY_ROOT}/releases/${TAG}"
|
||||
else
|
||||
OUTPUT_CANDIDATE="${REQUESTED_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/${TAG}}"
|
||||
fi
|
||||
OUTPUT_DIRECTORY="$(
|
||||
"$PYTHON_BIN" -c \
|
||||
'import sys; from pathlib import Path; from scripts.release_lib import _safe_output_directory; print(_safe_output_directory(Path(sys.argv[1])))' \
|
||||
"$OUTPUT_CANDIDATE"
|
||||
)"
|
||||
|
||||
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
|
||||
mkdir -p -- "$OUTPUT_DIRECTORY"
|
||||
cp -- "$NOTES_PATH" "${OUTPUT_DIRECTORY}/release-notes.md"
|
||||
printf 'Release notes written to %s\n' \
|
||||
"${OUTPUT_DIRECTORY}/release-notes.md"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "$SIGNING_KEY" ]]
|
||||
then
|
||||
printf '%s\n' 'A release signing key is required.' >&2
|
||||
printf '%s\n' \
|
||||
'Use --signing-key PATH or set PTLINUX_SIGNING_KEY to a path outside the repository.' >&2
|
||||
exit 1
|
||||
fi
|
||||
SIGNING_KEY="$(
|
||||
"$PYTHON_BIN" -c \
|
||||
'import sys
|
||||
from pathlib import Path
|
||||
from scripts.release_lib import ReleaseToolError, _safe_signing_key_path
|
||||
try:
|
||||
print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2])))
|
||||
except ReleaseToolError as exc:
|
||||
print(f"Invalid release signing key: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)' \
|
||||
"$SIGNING_KEY" "$OUTPUT_DIRECTORY"
|
||||
)"
|
||||
|
||||
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 [[ ! -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
|
||||
|
||||
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; .artifacts/ 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.'
|
||||
1574
scripts/release_lib.py
Normal file
1574
scripts/release_lib.py
Normal file
File diff suppressed because it is too large
Load Diff
25
scripts/repository_paths.py
Normal file
25
scripts/repository_paths.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Repository-only paths shared by maintainer scripts and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
ARTIFACTS_ROOT = PROJECT_ROOT / ".artifacts"
|
||||
RELEASE_ARTIFACTS_ROOT = ARTIFACTS_ROOT / "releases"
|
||||
|
||||
|
||||
def ensure_project_imports() -> None:
|
||||
"""Put checkout-only import roots ahead of ambient Python installations."""
|
||||
|
||||
ordered = [SOURCE_ROOT, PROJECT_ROOT]
|
||||
for path in ordered:
|
||||
text = str(path)
|
||||
while text in sys.path:
|
||||
sys.path.remove(text)
|
||||
for path in reversed(ordered):
|
||||
if path.is_dir():
|
||||
sys.path.insert(0, str(path))
|
||||
85
scripts/set_version.py
Executable file
85
scripts/set_version.py
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import PROJECT_ROOT, ensure_project_imports
|
||||
else:
|
||||
from repository_paths import PROJECT_ROOT, ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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())
|
||||
48
scripts/sign_metadata.py
Executable file
48
scripts/sign_metadata.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import SOURCE_ROOT, ensure_project_imports
|
||||
else:
|
||||
from repository_paths import SOURCE_ROOT, ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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=SOURCE_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())
|
||||
70
scripts/validate_release_remote.py
Normal file
70
scripts/validate_release_remote.py
Normal 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
31
scripts/verify-release.sh
Executable 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[@]}"
|
||||
38
scripts/verify_metadata.py
Normal file
38
scripts/verify_metadata.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/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
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import SOURCE_ROOT, ensure_project_imports
|
||||
else:
|
||||
from repository_paths import SOURCE_ROOT, ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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=SOURCE_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())
|
||||
50
scripts/verify_release.py
Executable file
50
scripts/verify_release.py
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify one complete release artifact set before publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
if __package__:
|
||||
from .repository_paths import ensure_project_imports
|
||||
else:
|
||||
from repository_paths import ensure_project_imports
|
||||
|
||||
ensure_project_imports()
|
||||
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())
|
||||
@@ -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
22
src/ptdiag/artifacts.py
Normal 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
72
src/ptdiag/assbin.py
Normal 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",
|
||||
]
|
||||
@@ -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
775
src/ptdiag/experiment.py
Normal 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,
|
||||
)
|
||||
1
src/ptdiag/experiments/__init__.py
Normal file
1
src/ptdiag/experiments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Controlled diagnostic experiment implementation modules."""
|
||||
287
src/ptdiag/experiments/cleanup.py
Normal file
287
src/ptdiag/experiments/cleanup.py
Normal 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
|
||||
257
src/ptdiag/experiments/evidence.py
Normal file
257
src/ptdiag/experiments/evidence.py
Normal 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)
|
||||
332
src/ptdiag/experiments/execution.py
Normal file
332
src/ptdiag/experiments/execution.py
Normal 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")),
|
||||
},
|
||||
}
|
||||
212
src/ptdiag/experiments/log_selection.py
Normal file
212
src/ptdiag/experiments/log_selection.py
Normal 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", [])
|
||||
)
|
||||
18
src/ptdiag/experiments/models.py
Normal file
18
src/ptdiag/experiments/models.py
Normal 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."""
|
||||
624
src/ptdiag/experiments/preparation.py
Normal file
624
src/ptdiag/experiments/preparation.py
Normal 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
40
src/ptdiag/logs.py
Normal 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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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, ...],
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.6.7"
|
||||
__version__ = "1.0.8"
|
||||
75
src/ptlinux/__main__.py
Normal file
75
src/ptlinux/__main__.py
Normal 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())
|
||||
2
src/ptlinux/application/__init__.py
Normal file
2
src/ptlinux/application/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Application workflows shared by the CLI and dashboard entry points."""
|
||||
|
||||
2
src/ptlinux/application/commands/__init__.py
Normal file
2
src/ptlinux/application/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Feature-oriented command workflows."""
|
||||
|
||||
190
src/ptlinux/application/commands/health.py
Normal file
190
src/ptlinux/application/commands/health.py
Normal 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
|
||||
479
src/ptlinux/application/commands/installation.py
Normal file
479
src/ptlinux/application/commands/installation.py
Normal 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
|
||||
|
||||
138
src/ptlinux/application/commands/launcher_update.py
Normal file
138
src/ptlinux/application/commands/launcher_update.py
Normal 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
|
||||
1138
src/ptlinux/application/commands/play.py
Normal file
1138
src/ptlinux/application/commands/play.py
Normal file
File diff suppressed because it is too large
Load Diff
32
src/ptlinux/application/commands/settings.py
Normal file
32
src/ptlinux/application/commands/settings.py
Normal 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
|
||||
245
src/ptlinux/application/common.py
Normal file
245
src/ptlinux/application/common.py
Normal 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
332
src/ptlinux/changelog.py
Normal 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
918
src/ptlinux/cli.py
Normal 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)
|
||||
@@ -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)
|
||||
889
src/ptlinux/container_host.py
Normal file
889
src/ptlinux/container_host.py
Normal 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user