Compare commits
46 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 |
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
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -3,25 +3,35 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.idea/
|
||||
.run/
|
||||
|
||||
# Legacy safety net only. Release tooling requires private keys outside the
|
||||
# checkout and generated artifact tree.
|
||||
release-keys/
|
||||
config/local.toml
|
||||
diagnostics/config/local.toml
|
||||
|
||||
# Local development, investigation, and release tooling. The production
|
||||
# repository intentionally contains only the installer/runtime sources.
|
||||
AGENTS.md
|
||||
AIO_INSTALLER.md
|
||||
BOOTSTRAP_WORKPLAN.md
|
||||
DEBUGGING.md
|
||||
GITEA_UPDATES.md
|
||||
README.md
|
||||
config/
|
||||
configs/
|
||||
findings/
|
||||
# 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/
|
||||
testcases/
|
||||
tests/
|
||||
tools/
|
||||
dist/
|
||||
/native/
|
||||
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.9.0 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-9-0.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v0-9-0.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-9-0.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-9-0.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-9-0
|
||||
-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
|
||||
```
|
||||
|
||||
@@ -88,11 +88,3 @@ 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 and the host sandbox
|
||||
manually.
|
||||
|
||||
On Ubuntu, install and launcher-update operations run a harmless Bubblewrap
|
||||
user-namespace probe. If AppArmor produces the exact bwrap UID-map permission
|
||||
denial, the launcher can install and load a pinned upstream restrictive bwrap
|
||||
profile. It also repairs only UMU's known broken
|
||||
`bwrap-userns-restrict-umu` link. Authentication is required; the repair does
|
||||
not disable AppArmor or change global kernel security settings, and it refuses
|
||||
to install a competing profile when another bwrap policy is present.
|
||||
|
||||
127
install.sh
127
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"
|
||||
@@ -144,17 +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/ui/__init__.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/ui/dashboard.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" \
|
||||
@@ -204,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"
|
||||
@@ -212,35 +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"
|
||||
shopt -s globstar nullglob
|
||||
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"**/*.py
|
||||
do
|
||||
RELATIVE_MODULE="${SOURCE_MODULE#"${SOURCE_ROOT}/ptlinux/"}"
|
||||
case "$RELATIVE_MODULE" in
|
||||
desktop_entry.py|launcher_wrapper.py)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
install -D -m 0644 \
|
||||
"$SOURCE_MODULE" \
|
||||
"${STAGING_RELEASE}/ptlinux/${RELATIVE_MODULE}"
|
||||
done
|
||||
shopt -u globstar nullglob
|
||||
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 \
|
||||
@@ -281,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
|
||||
@@ -299,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
|
||||
@@ -320,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" \
|
||||
@@ -328,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" \
|
||||
@@ -419,7 +447,8 @@ 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
|
||||
|
||||
|
||||
4
packaging/install-dependencies.sh
Executable file → Normal file
4
packaging/install-dependencies.sh
Executable file → Normal file
@@ -380,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" ]]
|
||||
|
||||
1809
ptdiag/experiment.py
1809
ptdiag/experiment.py
File diff suppressed because it is too large
Load Diff
1377
ptlinux/cli.py
1377
ptlinux/cli.py
File diff suppressed because it is too large
Load Diff
1956
ptlinux/depot.py
1956
ptlinux/depot.py
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
"""Native X11 command construction for the managed 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
|
||||
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
@@ -1,434 +0,0 @@
|
||||
"""Launcher update history, install, and rollback dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...paths import AppPaths
|
||||
from ...self_update import (
|
||||
LauncherUpdateCheck,
|
||||
LauncherUpdateHistory,
|
||||
inspect_launcher_update_history,
|
||||
)
|
||||
from ...tk_chrome import (
|
||||
centered_window_geometry,
|
||||
create_window_chrome,
|
||||
show_themed_dialog,
|
||||
)
|
||||
from ..viewmodels.dashboard import fitted_window_size
|
||||
from ..viewmodels.downloader import format_bytes
|
||||
|
||||
|
||||
class LauncherUpdateDialog:
|
||||
"""Own the update window and expose small refresh/error entry points."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Any,
|
||||
*,
|
||||
paths: AppPaths,
|
||||
launcher_version: str,
|
||||
ui_scale: float,
|
||||
window_icon: Any | None,
|
||||
apply_window_icon: Callable[[Any], None],
|
||||
get_check: Callable[[], LauncherUpdateCheck | None],
|
||||
is_busy: Callable[[], bool],
|
||||
request_check: Callable[[], None],
|
||||
install_and_restart: Callable[[str], None],
|
||||
rollback_and_restart: Callable[[str], None],
|
||||
open_article: Callable[[str], None],
|
||||
append_log: Callable[[str], None],
|
||||
) -> None:
|
||||
self._root = root
|
||||
self._paths = paths
|
||||
self._launcher_version = launcher_version
|
||||
self._ui_scale = ui_scale
|
||||
self._window_icon = window_icon
|
||||
self._apply_window_icon = apply_window_icon
|
||||
self._get_check = get_check
|
||||
self._is_busy = is_busy
|
||||
self._request_check = request_check
|
||||
self._install_and_restart = install_and_restart
|
||||
self._rollback_and_restart = rollback_and_restart
|
||||
self._open_article = open_article
|
||||
self._append_log = append_log
|
||||
self._window: Any = None
|
||||
self._required_reason: str | None = None
|
||||
self._installed_history: LauncherUpdateHistory | None = None
|
||||
self._status_value: Any = None
|
||||
self._details_value: Any = None
|
||||
self._history: Any = None
|
||||
self._release_notes_button: Any = None
|
||||
self._check_button: Any = None
|
||||
self._rollback_button: Any = None
|
||||
self._install_button: Any = None
|
||||
|
||||
def open(self, required_reason: str | None = None) -> None:
|
||||
if self._window is not None and self._window.winfo_exists():
|
||||
if required_reason is not None:
|
||||
self._required_reason = required_reason
|
||||
self._window.deiconify()
|
||||
self._window.lift()
|
||||
self._window.focus_set()
|
||||
self.refresh()
|
||||
return
|
||||
self._required_reason = required_reason
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
dialog = tk.Toplevel(self._root)
|
||||
dialog.withdraw()
|
||||
self._window = dialog
|
||||
dialog.title("PristonTale EU — Launcher Updates")
|
||||
dialog.resizable(False, False)
|
||||
dialog.configure(background="#0c0908")
|
||||
dialog.transient(self._root)
|
||||
self._apply_window_icon(dialog)
|
||||
updates_chrome = create_window_chrome(
|
||||
dialog,
|
||||
title="PRISTONTALE EU",
|
||||
subtitle="LAUNCHER UPDATES",
|
||||
window_icon=self._window_icon,
|
||||
show_close=True,
|
||||
show_minimize=False,
|
||||
allow_close=True,
|
||||
)
|
||||
|
||||
updates_header = tk.Frame(updates_chrome.content, background="#24170f")
|
||||
updates_header.pack(fill="x")
|
||||
tk_accent = tk.Frame(updates_header, background="#d98927", width=5)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
header_copy = tk.Frame(updates_header, background="#24170f")
|
||||
header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14)
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="SIGNED STABLE CHANNEL",
|
||||
background="#24170f",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 8, "bold"),
|
||||
).pack(anchor="w")
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="Launcher Updates",
|
||||
background="#24170f",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 18, "bold"),
|
||||
).pack(anchor="w", pady=(2, 0))
|
||||
|
||||
content = ttk.Frame(
|
||||
updates_chrome.content,
|
||||
style="Root.TFrame",
|
||||
padding=(16, 14, 16, 16),
|
||||
)
|
||||
content.pack(fill="both", expand=True)
|
||||
content.columnconfigure(0, weight=1)
|
||||
content.rowconfigure(1, weight=1)
|
||||
self._status_value = tk.StringVar(value="Checking the signed update channel…")
|
||||
self._details_value = tk.StringVar(value="")
|
||||
overview = ttk.LabelFrame(
|
||||
content,
|
||||
text="UPDATE STATUS",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
overview.grid(row=0, column=0, sticky="ew")
|
||||
overview.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
overview,
|
||||
textvariable=self._status_value,
|
||||
style="SettingsText.TLabel",
|
||||
wraplength=round(480 * self._ui_scale),
|
||||
justify="left",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(
|
||||
overview,
|
||||
textvariable=self._details_value,
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=round(480 * self._ui_scale),
|
||||
justify="left",
|
||||
).grid(row=1, column=0, sticky="w", pady=(6, 0))
|
||||
self._release_notes_button = ttk.Button(
|
||||
overview,
|
||||
text="VIEW RELEASE NOTES",
|
||||
style="Secondary.TButton",
|
||||
state="disabled",
|
||||
command=self._view_release_notes,
|
||||
)
|
||||
self._release_notes_button.grid(
|
||||
row=0,
|
||||
column=1,
|
||||
rowspan=2,
|
||||
sticky="e",
|
||||
padx=(12, 0),
|
||||
)
|
||||
|
||||
history_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="INSTALLED HISTORY",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=12,
|
||||
)
|
||||
history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
|
||||
history_card.columnconfigure(0, weight=1)
|
||||
history_card.rowconfigure(0, weight=1)
|
||||
self._history = ttk.Treeview(
|
||||
history_card,
|
||||
columns=("version", "state", "installed"),
|
||||
show="headings",
|
||||
height=6,
|
||||
style="Update.Treeview",
|
||||
selectmode="browse",
|
||||
)
|
||||
self._history.heading("version", text="VERSION", anchor="w")
|
||||
self._history.heading("state", text="STATE", anchor="w")
|
||||
self._history.heading("installed", text="INSTALLED", anchor="w")
|
||||
self._history.column(
|
||||
"version",
|
||||
width=round(120 * self._ui_scale),
|
||||
minwidth=round(80 * self._ui_scale),
|
||||
stretch=False,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.column(
|
||||
"state",
|
||||
width=round(150 * self._ui_scale),
|
||||
minwidth=round(100 * self._ui_scale),
|
||||
stretch=False,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.column(
|
||||
"installed",
|
||||
width=round(360 * self._ui_scale),
|
||||
minwidth=round(180 * self._ui_scale),
|
||||
stretch=True,
|
||||
anchor="w",
|
||||
)
|
||||
self._history.grid(row=0, column=0, sticky="nsew")
|
||||
history_scrollbar = ttk.Scrollbar(
|
||||
history_card,
|
||||
orient="vertical",
|
||||
style="News.Vertical.TScrollbar",
|
||||
command=self._history.yview,
|
||||
)
|
||||
history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0))
|
||||
self._history.configure(yscrollcommand=history_scrollbar.set)
|
||||
|
||||
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
|
||||
footer.grid(row=2, column=0, sticky="ew", pady=(12, 0))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
self._check_button = ttk.Button(
|
||||
footer,
|
||||
text="CHECK AGAIN",
|
||||
style="Secondary.TButton",
|
||||
command=self._check_again,
|
||||
)
|
||||
self._check_button.grid(row=0, column=0, sticky="w")
|
||||
self._rollback_button = ttk.Button(
|
||||
footer,
|
||||
text="ROLL BACK & RESTART",
|
||||
style="Secondary.TButton",
|
||||
state="disabled",
|
||||
command=self._rollback_update,
|
||||
)
|
||||
self._rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0))
|
||||
self._install_button = ttk.Button(
|
||||
footer,
|
||||
text="INSTALL & RESTART",
|
||||
style="Play.TButton",
|
||||
state="disabled",
|
||||
command=self._install_update,
|
||||
)
|
||||
self._install_button.grid(row=0, column=2, sticky="e", padx=(8, 0))
|
||||
close_button = ttk.Button(
|
||||
footer,
|
||||
text="CLOSE",
|
||||
style="Secondary.TButton",
|
||||
command=self.close,
|
||||
)
|
||||
close_button.grid(row=0, column=3, sticky="e", padx=(8, 0))
|
||||
|
||||
updates_chrome.set_close_command(self.close)
|
||||
dialog.protocol("WM_DELETE_WINDOW", self.close)
|
||||
dialog.bind("<Escape>", lambda _event: self.close())
|
||||
self.refresh()
|
||||
if self._get_check() is None:
|
||||
self._check_again()
|
||||
dialog.update_idletasks()
|
||||
updates_width, updates_height = fitted_window_size(
|
||||
760,
|
||||
620,
|
||||
scale=self._ui_scale,
|
||||
screen_width=dialog.winfo_screenwidth(),
|
||||
screen_height=dialog.winfo_screenheight(),
|
||||
)
|
||||
dialog.geometry(
|
||||
centered_window_geometry(
|
||||
updates_width,
|
||||
updates_height,
|
||||
parent_x=self._root.winfo_rootx(),
|
||||
parent_y=self._root.winfo_rooty(),
|
||||
parent_width=self._root.winfo_width(),
|
||||
parent_height=self._root.winfo_height(),
|
||||
)
|
||||
)
|
||||
dialog.deiconify()
|
||||
dialog.lift()
|
||||
self._history.focus_set()
|
||||
|
||||
def close(self) -> None:
|
||||
window = self._window
|
||||
self._window = None
|
||||
if window is not None and window.winfo_exists():
|
||||
window.destroy()
|
||||
|
||||
def refresh(self) -> None:
|
||||
if self._window is None or not self._window.winfo_exists():
|
||||
return
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._history is not None
|
||||
assert self._release_notes_button is not None
|
||||
assert self._check_button is not None
|
||||
assert self._rollback_button is not None
|
||||
assert self._install_button is not None
|
||||
check = self._get_check()
|
||||
try:
|
||||
self._installed_history = inspect_launcher_update_history(self._paths)
|
||||
except RuntimeError as error:
|
||||
self._installed_history = None
|
||||
self._append_log(f"Could not inspect launcher update history: {error}")
|
||||
for item in self._history.get_children():
|
||||
self._history.delete(item)
|
||||
if self._installed_history is not None:
|
||||
for release in self._installed_history.releases:
|
||||
state_label = (
|
||||
"Current"
|
||||
if release.active
|
||||
else "Rollback target"
|
||||
if release.previous
|
||||
else "Retained"
|
||||
)
|
||||
self._history.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(release.version, state_label, release.installed_at),
|
||||
)
|
||||
if not self._history.get_children():
|
||||
self._history.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(self._launcher_version, "Running", "Not recorded"),
|
||||
)
|
||||
busy = self._is_busy()
|
||||
rollback_available = (
|
||||
self._installed_history is not None
|
||||
and self._installed_history.previous_version is not None
|
||||
and not busy
|
||||
)
|
||||
self._rollback_button.configure(
|
||||
state="normal" if rollback_available else "disabled"
|
||||
)
|
||||
self._check_button.configure(state="disabled" if busy else "normal")
|
||||
if check is None:
|
||||
self._status_value.set("Checking the signed update channel…")
|
||||
self._details_value.set(
|
||||
"Installed launcher "
|
||||
+ self._launcher_version
|
||||
+ " · release information pending"
|
||||
)
|
||||
self._install_button.configure(state="disabled")
|
||||
self._release_notes_button.configure(state="disabled")
|
||||
return
|
||||
status_prefix = (
|
||||
f"Compatibility update required: {self._required_reason}\n"
|
||||
if self._required_reason is not None
|
||||
else ""
|
||||
)
|
||||
self._status_value.set(status_prefix + check.reason)
|
||||
self._details_value.set(
|
||||
f"Current {check.current_version} · Latest {check.latest_version} · "
|
||||
f"{check.manifest.channel.title()} channel · "
|
||||
f"{format_bytes(check.manifest.archive_size)} · "
|
||||
f"published {check.manifest.published_at} · "
|
||||
f"checked {check.checked_at or 'time unavailable'}"
|
||||
)
|
||||
self._install_button.configure(
|
||||
state=(
|
||||
"normal"
|
||||
if check.available and check.supported and not busy
|
||||
else "disabled"
|
||||
)
|
||||
)
|
||||
self._release_notes_button.configure(
|
||||
state="normal" if check.manifest.notes_url is not None else "disabled"
|
||||
)
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
if self._window is None or not self._window.winfo_exists():
|
||||
return
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._check_button is not None
|
||||
self._status_value.set("The signed update channel could not be checked.")
|
||||
self._details_value.set(message)
|
||||
self._check_button.configure(state="normal")
|
||||
|
||||
def _check_again(self) -> None:
|
||||
assert self._status_value is not None
|
||||
assert self._details_value is not None
|
||||
assert self._check_button is not None
|
||||
assert self._install_button is not None
|
||||
self._status_value.set("Checking the signed update channel…")
|
||||
self._details_value.set("Authenticating the stable manifest and signature.")
|
||||
self._check_button.configure(state="disabled")
|
||||
self._install_button.configure(state="disabled")
|
||||
self._request_check()
|
||||
|
||||
def _view_release_notes(self) -> None:
|
||||
check = self._get_check()
|
||||
if check is not None and check.manifest.notes_url is not None:
|
||||
self._open_article(check.manifest.notes_url)
|
||||
|
||||
def _install_update(self) -> None:
|
||||
check = self._get_check()
|
||||
if check is None or not check.available or not check.supported:
|
||||
return
|
||||
if not show_themed_dialog(
|
||||
self._window,
|
||||
title="Update launcher",
|
||||
message=(
|
||||
f"Install signed launcher {check.latest_version} "
|
||||
f"({format_bytes(check.manifest.archive_size)}) and restart now?"
|
||||
),
|
||||
kind="question",
|
||||
confirm=True,
|
||||
accept_text="INSTALL & RESTART",
|
||||
window_icon=self._window_icon,
|
||||
):
|
||||
return
|
||||
version_to_install = check.latest_version
|
||||
self.close()
|
||||
self._install_and_restart(version_to_install)
|
||||
|
||||
def _rollback_update(self) -> None:
|
||||
history = self._installed_history
|
||||
if history is None or history.previous_version is None:
|
||||
return
|
||||
if not show_themed_dialog(
|
||||
self._window,
|
||||
title="Roll back launcher",
|
||||
message=(
|
||||
f"Switch from launcher {history.current_version or self._launcher_version} "
|
||||
f"to {history.previous_version} and restart?\n\n"
|
||||
"The current release remains available as the next rollback target."
|
||||
),
|
||||
kind="warning",
|
||||
confirm=True,
|
||||
accept_text="ROLL BACK & RESTART",
|
||||
window_icon=self._window_icon,
|
||||
):
|
||||
return
|
||||
target_version = history.previous_version
|
||||
self.close()
|
||||
self._rollback_and_restart(target_version)
|
||||
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.9.0"
|
||||
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):
|
||||
@@ -53,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
|
||||
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",
|
||||
]
|
||||
@@ -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):
|
||||
@@ -86,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:
|
||||
@@ -94,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")
|
||||
@@ -114,7 +130,7 @@ 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"),
|
||||
),
|
||||
@@ -149,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.9.0"
|
||||
__version__ = "1.0.8"
|
||||
@@ -13,10 +13,17 @@ def main() -> int:
|
||||
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)
|
||||
@@ -36,13 +43,33 @@ def main() -> int:
|
||||
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)
|
||||
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))
|
||||
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)
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Callable, Mapping, Sequence
|
||||
|
||||
from . import __version__
|
||||
@@ -301,16 +302,30 @@ def repair_container_host(
|
||||
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:
|
||||
return replace(
|
||||
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
|
||||
@@ -372,12 +387,14 @@ def repair_container_host(
|
||||
error = str(exc)
|
||||
if rollback_error:
|
||||
error = f"{error}; rollback also failed: {rollback_error}"
|
||||
return replace(
|
||||
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,
|
||||
@@ -391,13 +408,63 @@ def repair_container_host(
|
||||
"still fails. "
|
||||
+ (after.recommendation or "Inspect the host security policy.")
|
||||
)
|
||||
return replace(
|
||||
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:
|
||||
1270
src/ptlinux/depot.py
Normal file
1270
src/ptlinux/depot.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user